Repository: oppia/oppia

Issue:
Potential loss of updates to the CommunityContributionStatsModel in the reduce step in the PopulateContributionStatsOneOffJob
**Describe the bug**
The CommunityContributionStatsModel is a singleton model, therefore the model GET and PUT must be done in a transaction to avoid loss of updates that come in rapid succession. Currently the reduce step in the PopulateContributionStatsOneOffJob has a GET and PUT that is not done in transaction.
Potential loss of updates to the CommunityContributionStatsModel in the reduce step in the PopulateContributionStatsOneOffJob
**Describe the bug**
The CommunityContributionStatsModel is a singleton model, therefore the model GET and PUT must be done in a transaction to avoid loss of updates that come in rapid succession. Currently the reduce step in the PopulateContributionStatsOneOffJob has a GET and PUT that is not done in transaction.


Discussion:

Resolving patch:
diff --git a/core/domain/suggestion_jobs_one_off.py b/core/domain/suggestion_jobs_one_off.py
--- a/core/domain/suggestion_jobs_one_off.py
+++ b/core/domain/suggestion_jobs_one_off.py
@@ -31,6 +31,7 @@
 
 (suggestion_models, user_models,) = models.Registry.import_models(
     [models.NAMES.suggestion, models.NAMES.user])
+transaction_services = models.Registry.import_transaction_services()
 
 
 class SuggestionMathRteAuditOneOffJob(jobs.BaseMapReduceOneOffJobManager):
@@ -304,35 +305,64 @@ def map(item):
 
     @staticmethod
     def reduce(key, values):
-        stats_model = suggestion_models.CommunityContributionStatsModel.get()
-
-        item_type, item_category, language_code = key.split(
-            PopulateContributionStatsOneOffJob.KEY_DELIMITER)
-
-        # Update the reviewer counts.
-        if item_type == PopulateContributionStatsOneOffJob.ITEM_TYPE_REVIEWER:
-            if item_category == (
-                    PopulateContributionStatsOneOffJob
-                    .ITEM_CATEGORY_QUESTION):
-                stats_model.question_reviewer_count = len(values)
-            elif item_category == (
-                    PopulateContributionStatsOneOffJob
-                    .ITEM_CATEGORY_TRANSLATION):
-                (
-                    stats_model
-                    .translation_reviewer_counts_by_lang_code[language_code]
-                ) = len(values)
-        # Update the suggestion counts.
-        else:
-            if item_category == suggestion_models.SUGGESTION_TYPE_ADD_QUESTION:
-                stats_model.question_suggestion_count = len(values)
-            elif item_category == (
-                    suggestion_models.SUGGESTION_TYPE_TRANSLATE_CONTENT):
-                (
-                    stats_model
-                    .translation_suggestion_counts_by_lang_code[language_code]
-                ) = len(values)
-
-        stats_model.put()
 
-        yield (key, len(values))
+        def _update_community_contribution_stats_transactional(
+                key, count_value):
+            """Updates the CommunityContributionStatsModel according to the
+            given key and count. The key contains the information for which
+            count to update with the given count value. The model GET and PUT
+            must be done in a transaction to avoid loss of updates that come in
+            rapid succession.
+
+            Args:
+                key: str. A key containing the information regarding which count
+                    to update.
+                count_value: int. The count value for the given key.
+
+            Returns:
+                tuple(str, str). A 2-tuple that consists of the key and
+                count_value to yield outside of the transaction.
+            """
+            item_type, item_category, language_code = key.split(
+                PopulateContributionStatsOneOffJob.KEY_DELIMITER)
+
+            stats_model = (
+                suggestion_models.CommunityContributionStatsModel.get())
+
+            # Update the reviewer counts.
+            if item_type == (
+                    PopulateContributionStatsOneOffJob.ITEM_TYPE_REVIEWER):
+                if item_category == (
+                        PopulateContributionStatsOneOffJob
+                        .ITEM_CATEGORY_QUESTION):
+                    stats_model.question_reviewer_count = count_value
+                elif item_category == (
+                        PopulateContributionStatsOneOffJob
+                        .ITEM_CATEGORY_TRANSLATION):
+                    (
+                        stats_model
+                        .translation_reviewer_counts_by_lang_code[language_code]
+                    ) = count_value
+            # Update the suggestion counts.
+            else:
+                if item_category == (
+                        suggestion_models.SUGGESTION_TYPE_ADD_QUESTION):
+                    stats_model.question_suggestion_count = count_value
+                elif item_category == (
+                        suggestion_models.SUGGESTION_TYPE_TRANSLATE_CONTENT):
+                    (
+                        stats_model
+                        .translation_suggestion_counts_by_lang_code[
+                            language_code]
+                    ) = count_value
+
+            stats_model.put()
+            return key, count_value
+
+        key_from_transaction, count_value_from_transaction = (
+            transaction_services.run_in_transaction(
+                _update_community_contribution_stats_transactional, key,
+                len(values)))
+
+        # Only yield the values from the transactions.
+        yield (key_from_transaction, count_value_from_transaction)
diff --git a/core/storage/suggestion/gae_models.py b/core/storage/suggestion/gae_models.py
--- a/core/storage/suggestion/gae_models.py
+++ b/core/storage/suggestion/gae_models.py
@@ -628,6 +628,9 @@ class CommunityContributionStatsModel(base_models.BaseModel):
     total number of reviewers for each suggestion type and the total number of
     suggestions in review for each suggestion type. There is only ever one
     instance of this model, and its ID is COMMUNITY_CONTRIBUTION_STATS_MODEL_ID.
+
+    Note: since this is a singleton model, the model GET and PUT must be done in
+    a transaction to avoid the loss of updates that come in rapid succession.
     """
 
     # A dictionary where the keys represent the language codes that translation


Test patch: