summaryrefslogtreecommitdiff
path: root/src/mongo/gotools/test/qa-tests/buildscripts/resmokelib/testing/hooks.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/mongo/gotools/test/qa-tests/buildscripts/resmokelib/testing/hooks.py')
-rw-r--r--src/mongo/gotools/test/qa-tests/buildscripts/resmokelib/testing/hooks.py704
1 files changed, 704 insertions, 0 deletions
diff --git a/src/mongo/gotools/test/qa-tests/buildscripts/resmokelib/testing/hooks.py b/src/mongo/gotools/test/qa-tests/buildscripts/resmokelib/testing/hooks.py
new file mode 100644
index 00000000000..4c580fa8392
--- /dev/null
+++ b/src/mongo/gotools/test/qa-tests/buildscripts/resmokelib/testing/hooks.py
@@ -0,0 +1,704 @@
+"""
+Customize the behavior of a fixture by allowing special code to be
+executed before or after each test, and before or after each suite.
+"""
+
+from __future__ import absolute_import
+
+import os
+import sys
+
+import bson
+import pymongo
+
+from . import fixtures
+from . import testcases
+from .. import errors
+from .. import logging
+from .. import utils
+
+
+def make_custom_behavior(class_name, *args, **kwargs):
+ """
+ Factory function for creating CustomBehavior instances.
+ """
+
+ if class_name not in _CUSTOM_BEHAVIORS:
+ raise ValueError("Unknown custom behavior class '%s'" % (class_name))
+ return _CUSTOM_BEHAVIORS[class_name](*args, **kwargs)
+
+
+class CustomBehavior(object):
+ """
+ The common interface all CustomBehaviors will inherit from.
+ """
+
+ @staticmethod
+ def start_dynamic_test(test_case, test_report):
+ """
+ If a CustomBehavior wants to add a test case that will show up
+ in the test report, it should use this method to add it to the
+ report, since we will need to count it as a dynamic test to get
+ the stats in the summary information right.
+ """
+ test_report.startTest(test_case, dynamic=True)
+
+ def __init__(self, logger, fixture):
+ """
+ Initializes the CustomBehavior with the specified fixture.
+ """
+
+ if not isinstance(logger, logging.Logger):
+ raise TypeError("logger must be a Logger instance")
+
+ self.logger = logger
+ self.fixture = fixture
+
+ def before_suite(self, test_report):
+ """
+ The test runner calls this exactly once before they start
+ running the suite.
+ """
+ pass
+
+ def after_suite(self, test_report):
+ """
+ The test runner calls this exactly once after all tests have
+ finished executing. Be sure to reset the behavior back to its
+ original state so that it can be run again.
+ """
+ pass
+
+ def before_test(self, test_report):
+ """
+ Each test will call this before it executes.
+
+ Raises a TestFailure if the test should be marked as a failure,
+ or a ServerFailure if the fixture exits uncleanly or
+ unexpectedly.
+ """
+ pass
+
+ def after_test(self, test_report):
+ """
+ Each test will call this after it executes.
+
+ Raises a TestFailure if the test should be marked as a failure,
+ or a ServerFailure if the fixture exits uncleanly or
+ unexpectedly.
+ """
+ pass
+
+
+class CleanEveryN(CustomBehavior):
+ """
+ Restarts the fixture after it has ran 'n' tests.
+ On mongod-related fixtures, this will clear the dbpath.
+ """
+
+ DEFAULT_N = 20
+
+ def __init__(self, logger, fixture, n=DEFAULT_N):
+ CustomBehavior.__init__(self, logger, fixture)
+
+ # Try to isolate what test triggers the leak by restarting the fixture each time.
+ if "detect_leaks=1" in os.getenv("ASAN_OPTIONS", ""):
+ self.logger.info("ASAN_OPTIONS environment variable set to detect leaks, so restarting"
+ " the fixture after each test instead of after every %d.", n)
+ n = 1
+
+ self.n = n
+ self.tests_run = 0
+
+ def after_test(self, test_report):
+ self.tests_run += 1
+ if self.tests_run >= self.n:
+ self.logger.info("%d tests have been run against the fixture, stopping it...",
+ self.tests_run)
+ self.tests_run = 0
+
+ teardown_success = self.fixture.teardown()
+ self.logger.info("Starting the fixture back up again...")
+ self.fixture.setup()
+ self.fixture.await_ready()
+
+ # Raise this after calling setup in case --continueOnFailure was specified.
+ if not teardown_success:
+ raise errors.TestFailure("%s did not exit cleanly" % (self.fixture))
+
+
+class CheckReplDBHash(CustomBehavior):
+ """
+ Waits for replication after each test, then checks that the dbhahses
+ of all databases other than "local" match on the primary and all of
+ the secondaries. If any dbhashes do not match, logs information
+ about what was different (e.g. Different numbers of collections,
+ missing documents in a collection, mismatching documents, etc).
+
+ Compatible only with ReplFixture subclasses.
+ """
+
+ def __init__(self, logger, fixture):
+ if not isinstance(fixture, fixtures.ReplFixture):
+ raise TypeError("%s does not support replication" % (fixture.__class__.__name__))
+
+ CustomBehavior.__init__(self, logger, fixture)
+
+ self.test_case = testcases.TestCase(self.logger, "Hook", "#dbhash#")
+
+ self.started = False
+
+ def after_test(self, test_report):
+ """
+ After each test, check that the dbhash of the test database is
+ the same on all nodes in the replica set or master/slave
+ fixture.
+ """
+
+ try:
+ if not self.started:
+ CustomBehavior.start_dynamic_test(self.test_case, test_report)
+ self.started = True
+
+ # Wait until all operations have replicated.
+ self.fixture.await_repl()
+
+ success = True
+ sb = [] # String builder.
+
+ primary = self.fixture.get_primary()
+ primary_conn = utils.new_mongo_client(port=primary.port)
+
+ for secondary in self.fixture.get_secondaries():
+ read_preference = pymongo.ReadPreference.SECONDARY
+ secondary_conn = utils.new_mongo_client(port=secondary.port,
+ read_preference=read_preference)
+ # Skip arbiters.
+ if secondary_conn.admin.command("isMaster").get("arbiterOnly", False):
+ continue
+
+ all_matched = CheckReplDBHash._check_all_db_hashes(primary_conn,
+ secondary_conn,
+ sb)
+ if not all_matched:
+ sb.insert(0,
+ "One or more databases were different between the primary on port %d"
+ " and the secondary on port %d:"
+ % (primary.port, secondary.port))
+
+ success = all_matched and success
+
+ if not success:
+ # Adding failures to a TestReport requires traceback information, so we raise
+ # a 'self.test_case.failureException' that we will catch ourselves.
+ self.test_case.logger.info("\n ".join(sb))
+ raise self.test_case.failureException("The dbhashes did not match")
+ except self.test_case.failureException as err:
+ self.test_case.logger.exception("The dbhashes did not match.")
+ self.test_case.return_code = 1
+ test_report.addFailure(self.test_case, sys.exc_info())
+ test_report.stopTest(self.test_case)
+ raise errors.ServerFailure(err.args[0])
+ except pymongo.errors.WTimeoutError:
+ self.test_case.logger.exception("Awaiting replication timed out.")
+ self.test_case.return_code = 2
+ test_report.addError(self.test_case, sys.exc_info())
+ test_report.stopTest(self.test_case)
+ raise errors.StopExecution("Awaiting replication timed out")
+
+ def after_suite(self, test_report):
+ """
+ If we get to this point, the #dbhash# test must have been
+ successful, so add it to the test report.
+ """
+
+ if self.started:
+ self.test_case.logger.info("The dbhashes matched for all tests.")
+ self.test_case.return_code = 0
+ test_report.addSuccess(self.test_case)
+ # TestReport.stopTest() has already been called if there was a failure.
+ test_report.stopTest(self.test_case)
+
+ self.started = False
+
+ @staticmethod
+ def _check_all_db_hashes(primary_conn, secondary_conn, sb):
+ """
+ Returns true if for each non-local database, the dbhash command
+ returns the same MD5 hash on the primary as it does on the
+ secondary. Returns false otherwise.
+
+ Logs a message describing the differences if any database's
+ dbhash did not match.
+ """
+
+ # Overview of how we'll check that everything replicated correctly between these two nodes:
+ #
+ # - Check whether they have the same databases.
+ # - If not, log which databases are missing where, and dump the contents of any that are
+ # missing.
+ #
+ # - Check whether each database besides "local" gives the same md5 field as the result of
+ # running the dbhash command.
+ # - If not, check whether they have the same collections.
+ # - If not, log which collections are missing where, and dump the contents of any
+ # that are missing.
+ # - If so, check that the hash of each non-capped collection matches.
+ # - If any do not match, log the diff of the collection between the two nodes.
+
+ success = True
+
+ if not CheckReplDBHash._check_dbs_present(primary_conn, secondary_conn, sb):
+ return False
+
+ for db_name in primary_conn.database_names():
+ if db_name == "local":
+ continue # We don't expect this to match across different nodes.
+
+ matched = CheckReplDBHash._check_db_hash(primary_conn, secondary_conn, db_name, sb)
+ success = matched and success
+
+ return success
+
+ @staticmethod
+ def _check_dbs_present(primary_conn, secondary_conn, sb):
+ """
+ Returns true if the list of databases on the primary is
+ identical to the list of databases on the secondary, and false
+ otherwise.
+ """
+
+ success = True
+ primary_dbs = primary_conn.database_names()
+
+ # Can't run database_names() on secondary, so instead use the listDatabases command.
+ # TODO: Use database_names() once PYTHON-921 is resolved.
+ list_db_output = secondary_conn.admin.command("listDatabases")
+ secondary_dbs = [db["name"] for db in list_db_output["databases"]]
+
+ # There may be a difference in databases which is not considered an error, when
+ # the database only contains system collections. This difference is only logged
+ # when others are encountered, i.e., success = False.
+ missing_on_primary, missing_on_secondary = CheckReplDBHash._check_difference(
+ set(primary_dbs), set(secondary_dbs), "database")
+
+ for missing_db in missing_on_secondary:
+ db = primary_conn[missing_db]
+ coll_names = db.collection_names()
+ non_system_colls = [name for name in coll_names if not name.startswith("system.")]
+
+ # It is only an error if there are any non-system collections in the database,
+ # otherwise it's not well defined whether they should exist or not.
+ if non_system_colls:
+ sb.append("Database %s present on primary but not on secondary." % (missing_db))
+ CheckReplDBHash._dump_all_collections(db, non_system_colls, sb)
+ success = False
+
+ for missing_db in missing_on_primary:
+ db = secondary_conn[missing_db]
+
+ # Can't run collection_names() on secondary, so instead use the listCollections command.
+ # TODO: Always use collection_names() once PYTHON-921 is resolved. Then much of the
+ # logic that is duplicated here can be consolidated.
+ list_coll_output = db.command("listCollections")["cursor"]["firstBatch"]
+ coll_names = [coll["name"] for coll in list_coll_output]
+ non_system_colls = [name for name in coll_names if not name.startswith("system.")]
+
+ # It is only an error if there are any non-system collections in the database,
+ # otherwise it's not well defined if it should exist or not.
+ if non_system_colls:
+ sb.append("Database %s present on secondary but not on primary." % (missing_db))
+ CheckReplDBHash._dump_all_collections(db, non_system_colls, sb)
+ success = False
+
+ return success
+
+ @staticmethod
+ def _check_db_hash(primary_conn, secondary_conn, db_name, sb):
+ """
+ Returns true if the dbhash for 'db_name' matches on the primary
+ and the secondary, and false otherwise.
+
+ Appends a message to 'sb' describing the differences if the
+ dbhashes do not match.
+ """
+
+ primary_hash = primary_conn[db_name].command("dbhash")
+ secondary_hash = secondary_conn[db_name].command("dbhash")
+
+ if primary_hash["md5"] == secondary_hash["md5"]:
+ return True
+
+ success = CheckReplDBHash._check_dbs_eq(
+ primary_conn, secondary_conn, primary_hash, secondary_hash, db_name, sb)
+
+ if not success:
+ sb.append("Database %s has a different hash on the primary and the secondary"
+ " ([ %s ] != [ %s ]):"
+ % (db_name, primary_hash["md5"], secondary_hash["md5"]))
+
+ return success
+
+ @staticmethod
+ def _check_dbs_eq(primary_conn, secondary_conn, primary_hash, secondary_hash, db_name, sb):
+ """
+ Returns true if all non-capped collections had the same hash in
+ the dbhash response, and false otherwise.
+
+ Appends information to 'sb' about the differences between the
+ 'db_name' database on the primary and the 'db_name' database on
+ the secondary, if any.
+ """
+
+ success = True
+
+ primary_db = primary_conn[db_name]
+ secondary_db = secondary_conn[db_name]
+
+ primary_coll_hashes = primary_hash["collections"]
+ secondary_coll_hashes = secondary_hash["collections"]
+
+ primary_coll_names = set(primary_coll_hashes.keys())
+ secondary_coll_names = set(secondary_coll_hashes.keys())
+
+ missing_on_primary, missing_on_secondary = CheckReplDBHash._check_difference(
+ primary_coll_names, secondary_coll_names, "collection", sb=sb)
+
+ if missing_on_primary or missing_on_secondary:
+
+ # 'sb' already describes which collections are missing where.
+ for coll_name in missing_on_primary:
+ CheckReplDBHash._dump_all_documents(secondary_db, coll_name, sb)
+ for coll_name in missing_on_secondary:
+ CheckReplDBHash._dump_all_documents(primary_db, coll_name, sb)
+ return
+
+ for coll_name in primary_coll_names & secondary_coll_names:
+ primary_coll_hash = primary_coll_hashes[coll_name]
+ secondary_coll_hash = secondary_coll_hashes[coll_name]
+
+ if primary_coll_hash == secondary_coll_hash:
+ continue
+
+ # Ignore capped collections because they are not expected to match on all nodes.
+ if primary_db.command({"collStats": coll_name})["capped"]:
+ # Still fail if the collection is not capped on the secondary.
+ if not secondary_db.command({"collStats": coll_name})["capped"]:
+ success = False
+ sb.append("%s.%s collection is capped on primary but not on secondary."
+ % (primary_db.name, coll_name))
+ sb.append("%s.%s collection is capped, ignoring." % (primary_db.name, coll_name))
+ continue
+ # Still fail if the collection is capped on the secondary, but not on the primary.
+ elif secondary_db.command({"collStats": coll_name})["capped"]:
+ success = False
+ sb.append("%s.%s collection is capped on secondary but not on primary."
+ % (primary_db.name, coll_name))
+ continue
+
+ success = False
+ sb.append("Collection %s.%s has a different hash on the primary and the secondary"
+ " ([ %s ] != [ %s ]):"
+ % (db_name, coll_name, primary_coll_hash, secondary_coll_hash))
+ CheckReplDBHash._check_colls_eq(primary_db, secondary_db, coll_name, sb)
+
+ if success:
+ sb.append("All collections that were expected to match did.")
+ return success
+
+ @staticmethod
+ def _check_colls_eq(primary_db, secondary_db, coll_name, sb):
+ """
+ Appends information to 'sb' about the differences or between
+ the 'coll_name' collection on the primary and the 'coll_name'
+ collection on the secondary, if any.
+ """
+
+ codec_options = bson.CodecOptions(document_class=TypeSensitiveSON)
+
+ primary_coll = primary_db.get_collection(coll_name, codec_options=codec_options)
+ secondary_coll = secondary_db.get_collection(coll_name, codec_options=codec_options)
+
+ primary_docs = CheckReplDBHash._extract_documents(primary_coll)
+ secondary_docs = CheckReplDBHash._extract_documents(secondary_coll)
+
+ CheckReplDBHash._get_collection_diff(primary_docs, secondary_docs, sb)
+
+ @staticmethod
+ def _extract_documents(collection):
+ """
+ Returns a list of all documents in the collection, sorted by
+ their _id.
+ """
+
+ return [doc for doc in collection.find().sort("_id", pymongo.ASCENDING)]
+
+ @staticmethod
+ def _get_collection_diff(primary_docs, secondary_docs, sb):
+ """
+ Returns true if the documents in 'primary_docs' exactly match
+ the documents in 'secondary_docs', and false otherwise.
+
+ Appends information to 'sb' about what matched or did not match.
+ """
+
+ matched = True
+
+ # These need to be lists instead of sets because documents aren't hashable.
+ missing_on_primary = []
+ missing_on_secondary = []
+
+ p_idx = 0 # Keep track of our position in 'primary_docs'.
+ s_idx = 0 # Keep track of our position in 'secondary_docs'.
+
+ while p_idx < len(primary_docs) and s_idx < len(secondary_docs):
+ primary_doc = primary_docs[p_idx]
+ secondary_doc = secondary_docs[s_idx]
+
+ if primary_doc == secondary_doc:
+ p_idx += 1
+ s_idx += 1
+ continue
+
+ # We have mismatching documents.
+ matched = False
+
+ if primary_doc["_id"] == secondary_doc["_id"]:
+ sb.append("Mismatching document:")
+ sb.append(" primary: %s" % (primary_doc))
+ sb.append(" secondary: %s" % (secondary_doc))
+ p_idx += 1
+ s_idx += 1
+
+ # One node was missing a document. Since the documents are sorted by _id, the doc with
+ # the smaller _id was the one that was skipped.
+ elif primary_doc["_id"] < secondary_doc["_id"]:
+ missing_on_secondary.append(primary_doc)
+
+ # Only move past the doc that we know was skipped.
+ p_idx += 1
+
+ else: # primary_doc["_id"] > secondary_doc["_id"]
+ missing_on_primary.append(secondary_doc)
+
+ # Only move past the doc that we know was skipped.
+ s_idx += 1
+
+ # Check if there are any unmatched documents left.
+ while p_idx < len(primary_docs):
+ matched = False
+ missing_on_secondary.append(primary_docs[p_idx])
+ p_idx += 1
+ while s_idx < len(secondary_docs):
+ matched = False
+ missing_on_primary.append(secondary_docs[s_idx])
+ s_idx += 1
+
+ if not matched:
+ CheckReplDBHash._append_differences(
+ missing_on_primary, missing_on_secondary, "document", sb)
+ else:
+ sb.append("All documents matched.")
+
+ @staticmethod
+ def _check_difference(primary_set, secondary_set, item_type_name, sb=None):
+ """
+ Returns true if the contents of 'primary_set' and
+ 'secondary_set' are identical, and false otherwise. The sets
+ contain information about the primary and secondary,
+ respectively, e.g. the database names that exist on each node.
+
+ Appends information about anything that differed to 'sb'.
+ """
+
+ missing_on_primary = set()
+ missing_on_secondary = set()
+
+ for item in primary_set - secondary_set:
+ missing_on_secondary.add(item)
+
+ for item in secondary_set - primary_set:
+ missing_on_primary.add(item)
+
+ if sb is not None:
+ CheckReplDBHash._append_differences(
+ missing_on_primary, missing_on_secondary, item_type_name, sb)
+
+ return (missing_on_primary, missing_on_secondary)
+
+ @staticmethod
+ def _append_differences(missing_on_primary, missing_on_secondary, item_type_name, sb):
+ """
+ Given two iterables representing items that were missing on the
+ primary or the secondary respectively, append the information
+ about which items were missing to 'sb', if any.
+ """
+
+ if missing_on_primary:
+ sb.append("The following %ss were present on the secondary, but not on the"
+ " primary:" % (item_type_name))
+ for item in missing_on_primary:
+ sb.append(str(item))
+
+ if missing_on_secondary:
+ sb.append("The following %ss were present on the primary, but not on the"
+ " secondary:" % (item_type_name))
+ for item in missing_on_secondary:
+ sb.append(str(item))
+
+ @staticmethod
+ def _dump_all_collections(database, coll_names, sb):
+ """
+ Appends the contents of each of the collections in 'coll_names'
+ to 'sb'.
+ """
+
+ if coll_names:
+ sb.append("Database %s contains the following collections: %s"
+ % (database.name, coll_names))
+ for coll_name in coll_names:
+ CheckReplDBHash._dump_all_documents(database, coll_name, sb)
+ else:
+ sb.append("No collections in database %s." % (database.name))
+
+ @staticmethod
+ def _dump_all_documents(database, coll_name, sb):
+ """
+ Appends the contents of 'coll_name' to 'sb'.
+ """
+
+ docs = CheckReplDBHash._extract_documents(database[coll_name])
+ if docs:
+ sb.append("Documents in %s.%s:" % (database.name, coll_name))
+ for doc in docs:
+ sb.append(" %s" % (doc))
+ else:
+ sb.append("No documents in %s.%s." % (database.name, coll_name))
+
+class TypeSensitiveSON(bson.SON):
+ """
+ Extends bson.SON to perform additional type-checking of document values
+ to differentiate BSON types.
+ """
+
+ def items_with_types(self):
+ """
+ Returns a list of triples. Each triple consists of a field name, a
+ field value, and a field type for each field in the document.
+ """
+
+ return [(key, self[key], type(self[key])) for key in self]
+
+ def __eq__(self, other):
+ """
+ Comparison to another TypeSensitiveSON is order-sensitive and
+ type-sensitive while comparison to a regular dictionary ignores order
+ and type mismatches.
+ """
+
+ if isinstance(other, TypeSensitiveSON):
+ return (len(self) == len(other) and
+ self.items_with_types() == other.items_with_types())
+
+ raise TypeError("TypeSensitiveSON objects cannot be compared to other types")
+
+class ValidateCollections(CustomBehavior):
+ """
+ Runs full validation (db.collection.validate(true)) on all collections
+ in all databases on every standalone, or primary mongod. If validation
+ fails (validate.valid), then the validate return object is logged.
+
+ Compatible with all subclasses.
+ """
+ DEFAULT_FULL = True
+ DEFAULT_SCANDATA = True
+
+ def __init__(self, logger, fixture, full=DEFAULT_FULL, scandata=DEFAULT_SCANDATA):
+ CustomBehavior.__init__(self, logger, fixture)
+
+ if not isinstance(full, bool):
+ raise TypeError("Fixture option full is not specified as type bool")
+
+ if not isinstance(scandata, bool):
+ raise TypeError("Fixture option scandata is not specified as type bool")
+
+ self.test_case = testcases.TestCase(self.logger, "Hook", "#validate#")
+ self.started = False
+ self.full = full
+ self.scandata = scandata
+
+ def after_test(self, test_report):
+ """
+ After each test, run a full validation on all collections.
+ """
+
+ try:
+ if not self.started:
+ CustomBehavior.start_dynamic_test(self.test_case, test_report)
+ self.started = True
+
+ sb = [] # String builder.
+
+ # The self.fixture.port can be used for client connection to a
+ # standalone mongod, a replica-set primary, or mongos.
+ # TODO: Run collection validation on all nodes in a replica-set.
+ port = self.fixture.port
+ conn = utils.new_mongo_client(port=port)
+
+ success = ValidateCollections._check_all_collections(
+ conn, sb, self.full, self.scandata)
+
+ if not success:
+ # Adding failures to a TestReport requires traceback information, so we raise
+ # a 'self.test_case.failureException' that we will catch ourselves.
+ self.test_case.logger.info("\n ".join(sb))
+ raise self.test_case.failureException("Collection validation failed")
+ except self.test_case.failureException as err:
+ self.test_case.logger.exception("Collection validation failed")
+ self.test_case.return_code = 1
+ test_report.addFailure(self.test_case, sys.exc_info())
+ test_report.stopTest(self.test_case)
+ raise errors.ServerFailure(err.args[0])
+
+ def after_suite(self, test_report):
+ """
+ If we get to this point, the #validate# test must have been
+ successful, so add it to the test report.
+ """
+
+ if self.started:
+ self.test_case.logger.info("Collection validation passed for all tests.")
+ self.test_case.return_code = 0
+ test_report.addSuccess(self.test_case)
+ # TestReport.stopTest() has already been called if there was a failure.
+ test_report.stopTest(self.test_case)
+
+ self.started = False
+
+ @staticmethod
+ def _check_all_collections(conn, sb, full, scandata):
+ """
+ Returns true if for all databases and collections validate_collection
+ succeeds. Returns false otherwise.
+
+ Logs a message if any database's collection fails validate_collection.
+ """
+
+ success = True
+
+ for db_name in conn.database_names():
+ for coll_name in conn[db_name].collection_names():
+ try:
+ conn[db_name].validate_collection(coll_name, full=full, scandata=scandata)
+ except pymongo.errors.CollectionInvalid as err:
+ sb.append("Database %s, collection %s failed to validate:\n%s"
+ % (db_name, coll_name, err.args[0]))
+ success = False
+ return success
+
+
+_CUSTOM_BEHAVIORS = {
+ "CleanEveryN": CleanEveryN,
+ "CheckReplDBHash": CheckReplDBHash,
+ "ValidateCollections": ValidateCollections,
+}