Repository: oppia/oppia

Issue:
Create job to recompute statistics from event models in datastore
This implements the first part of Milestone 4 of the Statistics project.

We need to create an MR job which will recompute all statistics data using the event models in the datastore. This job should create a new analytics model (ExplorationStatsModel).

For details on how recomputation works: see the 'Recomputing from scratch' section of each event model in [this](https://docs.google.com/document/d/11ZAe7BZqYsCS60oIDwvXQ7xKH4wwrrffN3HyqsNFbAw/edit#bookmark=id.oq2z62w0g7ox)


Discussion:

Resolving patch:
diff --git a/core/domain/stats_jobs_one_off.py b/core/domain/stats_jobs_one_off.py
--- a/core/domain/stats_jobs_one_off.py
+++ b/core/domain/stats_jobs_one_off.py
@@ -18,8 +18,13 @@
 
 import ast
 import collections
+import copy
 import datetime
 
+import feconf
+import utils
+
+
 from core import jobs
 from core.domain import exp_domain
 from core.domain import exp_services
@@ -28,14 +33,226 @@
 from core.domain import stats_services
 from core.platform import models
 
-import feconf
-import utils
-
-(stats_models, exp_models) = models.Registry.import_models([
-    models.NAMES.statistics, models.NAMES.exploration
+(exp_models, stats_models,) = models.Registry.import_models([
+    models.NAMES.exploration, models.NAMES.statistics
 ])
 
 
+class RecomputeStatisticsOneOffJob(jobs.BaseMapReduceOneOffJobManager):
+    """A one-off job for recomputing creator statistics from the events
+    in the datastore. Should only be run in events of data corruption.
+    """
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [stats_models.StateCompleteEventLogEntryModel,
+                stats_models.AnswerSubmittedEventLogEntryModel,
+                stats_models.StateHitEventLogEntryModel,
+                stats_models.SolutionHitEventLogEntryModel,
+                stats_models.ExplorationActualStartEventLogEntryModel,
+                stats_models.CompleteExplorationEventLogEntryModel
+               ]
+
+    @staticmethod
+    def map(item):
+        if item.event_schema_version != 2:
+            return
+        if isinstance(item, stats_models.StateCompleteEventLogEntryModel):
+            yield (item.exp_id,
+                   {
+                       'event_type': feconf.EVENT_TYPE_STATE_COMPLETED,
+                       'version': item.exp_version,
+                       'state_name': item.state_name
+                   })
+        elif isinstance(
+                item, stats_models.AnswerSubmittedEventLogEntryModel):
+            yield (item.exp_id,
+                   {
+                       'event_type': feconf.EVENT_TYPE_ANSWER_SUBMITTED,
+                       'version': item.exp_version,
+                       'state_name': item.state_name,
+                       'is_feedback_useful': item.is_feedback_useful
+                   })
+        elif isinstance(item, stats_models.StateHitEventLogEntryModel):
+            yield (item.exploration_id,
+                   {
+                       'event_type': feconf.EVENT_TYPE_STATE_HIT,
+                       'version': item.exploration_version,
+                       'state_name': item.state_name,
+                       'session_id': item.session_id
+                   })
+        elif isinstance(item, stats_models.SolutionHitEventLogEntryModel):
+            yield (item.exp_id,
+                   {
+                       'event_type': feconf.EVENT_TYPE_SOLUTION_HIT,
+                       'version': item.exp_version,
+                       'state_name': item.state_name,
+                       'session_id': item.session_id
+                   })
+        elif isinstance(
+                item, stats_models.ExplorationActualStartEventLogEntryModel):
+            yield (item.exp_id,
+                   {
+                       'event_type': feconf.EVENT_TYPE_ACTUAL_START_EXPLORATION,
+                       'version': item.exp_version,
+                       'state_name': item.state_name
+                   })
+        elif isinstance(
+                item, stats_models.CompleteExplorationEventLogEntryModel):
+            yield (item.exploration_id,
+                   {
+                       'event_type': feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
+                       'version': item.exploration_version,
+                       'state_name': item.state_name
+                   })
+
+    @staticmethod
+    def reduce(exp_id, values):
+        values = map(ast.literal_eval, values)
+        sorted_events_dicts = sorted(values, key=lambda x: x['version'])
+
+        # Find the latest version number
+        exploration = exp_services.get_exploration_by_id(exp_id)
+        latest_exp_version = exploration.version
+        versions = range(1, latest_exp_version + 1)
+
+        # Get a copy of the corrupted statistics models to copy uncorrupted
+        # v1 fields
+        old_stats = stats_services.get_multiple_exploration_stats_by_version(
+            exp_id, versions)
+        # Get list of snapshot models for each version of the exploration
+        snapshots_by_version = (
+            exp_models.ExplorationModel.get_snapshots_metadata(
+                exp_id, versions))
+
+        exp_stats_dicts = []
+        event_dict_idx = 0
+        event_dict = sorted_events_dicts[event_dict_idx]
+        for version in versions:
+            datastore_stats_for_version = old_stats[version-1]
+            if version == 1:
+                # Reset the possibly corrupted stats
+                datastore_stats_for_version.num_starts_v2 = 0
+                datastore_stats_for_version.num_completions_v2 = 0
+                datastore_stats_for_version.num_actual_starts_v2 = 0
+                for state_stats in (datastore_stats_for_version.
+                                    state_stats_mapping.values()):
+                    state_stats.total_answers_count_v2 = 0
+                    state_stats.useful_feedback_count_v2 = 0
+                    state_stats.total_hit_count_v2 = 0
+                    state_stats.first_hit_count_v2 = 0
+                    state_stats.num_times_solution_viewed_v2 = 0
+                    state_stats.num_completions_v2 = 0
+                exp_stats_dict = datastore_stats_for_version.to_dict()
+            else:
+                change_list = snapshots_by_version[version - 1]['commit_cmds']
+                # Copy recomputed v2 events from previous version
+                prev_stats_dict = copy.deepcopy(exp_stats_dicts[-1])
+                prev_stats_dict = (
+                    RecomputeStatisticsOneOffJob._apply_state_name_changes(
+                        prev_stats_dict, change_list))
+                # Copy uncorrupt v1 stats
+                prev_stats_dict['num_starts_v1'] = (
+                    datastore_stats_for_version.num_starts_v1)
+                prev_stats_dict['num_completions_v1'] = (
+                    datastore_stats_for_version.num_completions_v1)
+                prev_stats_dict['num_actual_starts_v1'] = (
+                    datastore_stats_for_version.num_actual_starts_v1)
+                state_stats_mapping = prev_stats_dict['state_stats_mapping']
+                for state in state_stats_mapping:
+                    state_stats_mapping[state]['total_answers_count_v1'] = (
+                        datastore_stats_for_version.state_stats_mapping[state]
+                        .total_answers_count_v1)
+                    state_stats_mapping[state]['useful_feedback_count_v1'] = (
+                        datastore_stats_for_version.state_stats_mapping[state]
+                        .useful_feedback_count_v1)
+                    state_stats_mapping[state]['total_hit_count_v1'] = (
+                        datastore_stats_for_version.state_stats_mapping[state]
+                        .total_hit_count_v1)
+                    state_stats_mapping[state]['first_hit_count_v1'] = (
+                        datastore_stats_for_version.state_stats_mapping[state]
+                        .first_hit_count_v1)
+                    state_stats_mapping[state]['num_completions_v1'] = (
+                        datastore_stats_for_version.state_stats_mapping[state]
+                        .num_completions_v1)
+                exp_stats_dict = copy.deepcopy(prev_stats_dict)
+
+            # Compute the statistics for events corresponding to this version
+            state_hit_session_ids, solution_hit_session_ids = set(), set()
+            while event_dict['version'] == version:
+                state_stats = (exp_stats_dict['state_stats_mapping'][
+                    event_dict['state_name']])
+                if event_dict['event_type'] == (
+                        feconf.EVENT_TYPE_ACTUAL_START_EXPLORATION):
+                    exp_stats_dict['num_actual_starts_v2'] += 1
+                elif event_dict['event_type'] == (
+                        feconf.EVENT_TYPE_COMPLETE_EXPLORATION):
+                    exp_stats_dict['num_completions_v2'] += 1
+                elif event_dict['event_type'] == (
+                        feconf.EVENT_TYPE_ANSWER_SUBMITTED):
+                    state_stats['total_answers_count_v2'] += 1
+                    if event_dict['is_feedback_useful']:
+                        state_stats['useful_feedback_count_v2'] += 1
+                elif event_dict['event_type'] == feconf.EVENT_TYPE_STATE_HIT:
+                    state_stats['total_hit_count_v2'] += 1
+                    state_hit_key = (
+                        event_dict['session_id'] + event_dict['state_name'])
+                    if state_hit_key not in state_hit_session_ids:
+                        state_stats['first_hit_count_v2'] += 1
+                        state_hit_session_ids.add(state_hit_key)
+                elif event_dict['event_type'] == feconf.EVENT_TYPE_SOLUTION_HIT:
+                    solution_hit_key = (
+                        event_dict['session_id'] + event_dict['state_name'])
+                    if solution_hit_key not in solution_hit_session_ids:
+                        state_stats['num_times_solution_viewed_v2'] += 1
+                        solution_hit_session_ids.add(solution_hit_key)
+                elif event_dict['event_type'] == (
+                        feconf.EVENT_TYPE_STATE_COMPLETED):
+                    state_stats['num_completions_v2'] += 1
+                event_dict_idx += 1
+                if event_dict_idx < len(sorted_events_dicts):
+                    event_dict = sorted_events_dicts[event_dict_idx]
+                else:
+                    break
+
+            exp_stats_dicts.append(copy.deepcopy(exp_stats_dict))
+        stats_models.ExplorationStatsModel.save_multi(exp_stats_dicts)
+
+    @classmethod
+    def _apply_state_name_changes(cls, prev_stats_dict, change_list):
+        """Update the state_stats_mapping to correspond with the changes
+        in change_list.
+
+        Args:
+            prev_stats_dict: dict. A dict representation of an
+                ExplorationStatsModel.
+            change_list: list(dict). A list of all of the commit cmds from
+                the old_stats_model up to the next version.
+
+        Returns:
+            dict. A dict representation of an ExplorationStatsModel
+                with updated state_stats_mapping and version.
+        """
+        # Handling state additions, deletions and renames.
+        for change_dict in change_list:
+            if change_dict['cmd'] == exp_domain.CMD_ADD_STATE:
+
+                prev_stats_dict['state_stats_mapping'][change_dict[
+                    'state_name']] = (stats_domain.StateStats
+                                      .create_default())
+            elif change_dict['cmd'] == exp_domain.CMD_DELETE_STATE:
+                prev_stats_dict['state_stats_mapping'].pop(change_dict[
+                    'state_name'])
+            elif change_dict['cmd'] == exp_domain.CMD_RENAME_STATE:
+                prev_stats_dict['state_stats_mapping'][
+                    change_dict['new_state_name']] = (
+                        prev_stats_dict['state_stats_mapping'].pop(
+                            change_dict['old_state_name']))
+        prev_stats_dict['exp_version'] += 1
+
+        return prev_stats_dict
+
+
 class GenerateV1StatisticsJob(jobs.BaseMapReduceOneOffJobManager):
     """A one-off migration of existing event model instances to generate
     ExplorationStatsModel instances for all existing explorations. This job
diff --git a/core/jobs_registry.py b/core/jobs_registry.py
--- a/core/jobs_registry.py
+++ b/core/jobs_registry.py
@@ -45,6 +45,7 @@
     exp_jobs_one_off.ViewableExplorationsAuditJob,
     feedback_jobs_one_off.FeedbackThreadMessagesCountOneOffJob,
     recommendations_jobs_one_off.ExplorationRecommendationsOneOffJob,
+    stats_jobs_one_off.RecomputeStatisticsOneOffJob,
     stats_jobs_one_off.GenerateV1StatisticsJob,
     stats_jobs_one_off.StatisticsAuditV1,
     stats_jobs_one_off.StatisticsAudit,


Test patch:
diff --git a/core/domain/stats_jobs_one_off_test.py b/core/domain/stats_jobs_one_off_test.py
--- a/core/domain/stats_jobs_one_off_test.py
+++ b/core/domain/stats_jobs_one_off_test.py
@@ -18,6 +18,9 @@
 
 import os
 
+import feconf
+import utils
+
 from core.domain import event_services
 from core.domain import exp_domain
 from core.domain import exp_services
@@ -28,11 +31,713 @@
 from core.platform.taskqueue import gae_taskqueue_services as taskqueue_services
 from core.tests import test_utils
 
-import feconf
-import utils
 
-(stats_models, exp_models) = models.Registry.import_models(
-    [models.NAMES.statistics, models.NAMES.exploration])
+(exp_models, stats_models,) = models.Registry.import_models(
+    [models.NAMES.exploration, models.NAMES.statistics])
+
+
+class RecomputeStateCompleteStatisticsTest(test_utils.GenericTestBase):
+    exp_id = 'exp_id'
+    state_b = 'b'
+
+    def setUp(self):
+        super(RecomputeStateCompleteStatisticsTest, self).setUp()
+        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
+
+        change_list = []
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+        change_list = [{'cmd': exp_domain.CMD_RENAME_STATE,
+                        'old_state_name': self.exp.init_state_name,
+                        'new_state_name': self.state_b}]
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+
+        stats_models.StateCompleteEventLogEntryModel(
+            id='id0',
+            exp_id=self.exp_id,
+            exp_version=1,
+            state_name=self.exp.init_state_name,
+            session_id='session_id_1',
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.StateCompleteEventLogEntryModel(
+            id='id1',
+            exp_id=self.exp_id,
+            exp_version=1,
+            state_name=self.exp.init_state_name,
+            session_id='session_id_2',
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.StateCompleteEventLogEntryModel(
+            id='id2',
+            exp_id=self.exp_id,
+            exp_version=1,
+            state_name=self.exp.init_state_name,
+            session_id='session_id_3',
+            time_spent_in_state_secs=1.0,
+            event_schema_version=1).put()
+        stats_models.StateCompleteEventLogEntryModel(
+            id='id3',
+            exp_id=self.exp_id,
+            exp_version=2,
+            state_name=self.exp.init_state_name,
+            session_id='session_id_4',
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.StateCompleteEventLogEntryModel(
+            id='id4',
+            exp_id=self.exp_id,
+            exp_version=3,
+            state_name=self.state_b,
+            session_id='session_id_4',
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+
+        state_stats_dict = {
+            'total_answers_count_v1': 3,
+            'total_answers_count_v2': 0,
+            'useful_feedback_count_v1': 3,
+            'useful_feedback_count_v2': 0,
+            'total_hit_count_v1': 3,
+            'total_hit_count_v2': 0,
+            'first_hit_count_v1': 3,
+            'first_hit_count_v2': 0,
+            'num_times_solution_viewed_v2': 0,
+            'num_completions_v1': 3,
+            'num_completions_v2': 9
+        }
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 1, 0, 0, 0, 0, 0, 0,
+            {
+                self.exp.init_state_name: state_stats_dict
+            })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
+            {
+                self.exp.init_state_name: state_stats_dict
+            })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 3, 0, 0, 0, 0, 0, 0,
+            {
+                self.state_b: state_stats_dict
+            })
+
+    def test_standard_operation(self):
+        job_id = (
+            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 1)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping[self.exp.init_state_name]
+        # Check the old event schema version was not counted.
+        self.assertEqual(state_stats['num_completions_v2'], 2)
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 2)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping[self.exp.init_state_name]
+        # Check that the new version counts events for the previous
+        # versions.
+        self.assertEqual(state_stats['num_completions_v2'], 3)
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 3)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping[self.state_b]
+        # Check that the new version with a renamed state still
+        # counts the events for the state in previous versions
+        # with the old name.
+        self.assertEqual(state_stats['num_completions_v2'], 4)
+
+
+class RecomputeAnswerSubmittedStatisticsTest(test_utils.GenericTestBase):
+    exp_id = 'exp_id'
+    exp_version = 1
+    session_id_1 = 'session_id_1'
+    session_id_2 = 'session_id_2'
+
+    def setUp(self):
+        super(RecomputeAnswerSubmittedStatisticsTest, self).setUp()
+        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
+        self.state = self.exp.init_state_name
+
+        change_list = [{'cmd': exp_domain.CMD_RENAME_STATE,
+                        'old_state_name': self.state,
+                        'new_state_name': 'b'}]
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+
+
+        stats_models.AnswerSubmittedEventLogEntryModel(
+            id='id1',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            is_feedback_useful=True,
+            event_schema_version=2).put()
+        stats_models.AnswerSubmittedEventLogEntryModel(
+            id='id2',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            is_feedback_useful=True,
+            event_schema_version=2).put()
+        stats_models.AnswerSubmittedEventLogEntryModel(
+            id='id3',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            is_feedback_useful=False,
+            event_schema_version=2).put()
+        stats_models.AnswerSubmittedEventLogEntryModel(
+            id='id4',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            is_feedback_useful=True,
+            event_schema_version=1).put()
+        stats_models.AnswerSubmittedEventLogEntryModel(
+            id='id5',
+            exp_id=self.exp_id,
+            exp_version=2,
+            state_name='b',
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            is_feedback_useful=True,
+            event_schema_version=2).put()
+
+        state_stats_dict = {
+            'total_answers_count_v1': 3,
+            'total_answers_count_v2': 9,
+            'useful_feedback_count_v1': 3,
+            'useful_feedback_count_v2': 9,
+            'total_hit_count_v1': 3,
+            'total_hit_count_v2': 0,
+            'first_hit_count_v1': 3,
+            'first_hit_count_v2': 0,
+            'num_times_solution_viewed_v2': 0,
+            'num_completions_v1': 3,
+            'num_completions_v2': 0
+        }
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0,
+            {
+                self.state: state_stats_dict
+            })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
+            {
+                'b': state_stats_dict
+            })
+
+    def test_standard_operation(self):
+        job_id = (
+            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, self.exp_version)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping[self.state]
+        self.assertEqual(state_stats['total_answers_count_v2'], 3)
+        self.assertEqual(state_stats['useful_feedback_count_v2'], 2)
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 2)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping['b']
+        self.assertEqual(state_stats['total_answers_count_v2'], 4)
+        self.assertEqual(state_stats['useful_feedback_count_v2'], 3)
+
+
+class RecomputeStateHitStatisticsTest(test_utils.GenericTestBase):
+    exp_id = 'exp_id'
+    # Start on version 2 as the update from version 1 to 2 is only to
+    # setup states for testing
+    exp_version = 1
+    session_id_1 = 'session_id_1'
+    session_id_2 = 'session_id_2'
+
+    def setUp(self):
+        super(RecomputeStateHitStatisticsTest, self).setUp()
+        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
+        self.state = self.exp.init_state_name
+
+        change_list = []
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+        change_list = [{'cmd': exp_domain.CMD_RENAME_STATE,
+                        'old_state_name': self.state,
+                        'new_state_name': 'b'}]
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+
+        stats_models.StateHitEventLogEntryModel(
+            id='id1',
+            event_type=feconf.EVENT_TYPE_STATE_HIT,
+            exploration_id=self.exp_id,
+            exploration_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+        stats_models.StateHitEventLogEntryModel(
+            id='id2',
+            event_type=feconf.EVENT_TYPE_STATE_HIT,
+            exploration_id=self.exp_id,
+            exploration_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+        stats_models.StateHitEventLogEntryModel(
+            id='id3',
+            event_type=feconf.EVENT_TYPE_STATE_HIT,
+            exploration_id=self.exp_id,
+            exploration_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_2,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+        stats_models.StateHitEventLogEntryModel(
+            id='id4',
+            event_type=feconf.EVENT_TYPE_STATE_HIT,
+            exploration_id=self.exp_id,
+            exploration_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_2,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=1).put()
+        stats_models.StateHitEventLogEntryModel(
+            id='id5',
+            event_type=feconf.EVENT_TYPE_STATE_HIT,
+            exploration_id=self.exp_id,
+            exploration_version=2,
+            state_name='a',
+            session_id=self.session_id_2,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+        stats_models.StateHitEventLogEntryModel(
+            id='id6',
+            event_type=feconf.EVENT_TYPE_STATE_HIT,
+            exploration_id=self.exp_id,
+            exploration_version=3,
+            state_name='b',
+            session_id=self.session_id_2,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+
+
+        state_stats_dict = {
+            'total_answers_count_v1': 3,
+            'total_answers_count_v2': 9,
+            'useful_feedback_count_v1': 3,
+            'useful_feedback_count_v2': 9,
+            'total_hit_count_v1': 3,
+            'total_hit_count_v2': 0,
+            'first_hit_count_v1': 3,
+            'first_hit_count_v2': 0,
+            'num_times_solution_viewed_v2': 0,
+            'num_completions_v1': 3,
+            'num_completions_v2': 0
+        }
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 1, 0, 0, 0, 0, 0, 0,
+            {
+                'a': state_stats_dict,
+                self.state: state_stats_dict
+            })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
+            {
+                'a': state_stats_dict,
+                self.state: state_stats_dict
+            })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 3, 0, 0, 0, 0, 0, 0,
+            {
+                'a': state_stats_dict,
+                'b': state_stats_dict
+            })
+
+    def test_standard_operation(self):
+        job_id = (
+            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, self.exp_version)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping[self.state]
+        self.assertEqual(state_stats['first_hit_count_v2'], 2)
+        self.assertEqual(state_stats['total_hit_count_v2'], 3)
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 3)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping['b']
+        self.assertEqual(state_stats['first_hit_count_v2'], 3)
+        self.assertEqual(state_stats['total_hit_count_v2'], 4)
+
+    def test_session_hitting_multiple_states(self):
+        job_id = (
+            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 2)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping['a']
+        self.assertEqual(state_stats['first_hit_count_v2'], 1)
+        self.assertEqual(state_stats['total_hit_count_v2'], 1)
+
+
+
+class RecomputeSolutionHitStatisticsTest(test_utils.GenericTestBase):
+    exp_id = 'exp_id'
+    exp_version = 1
+    session_id_1 = 'session_id_1'
+    session_id_2 = 'session_id_2'
+    session_id_3 = 'session_id_3'
+
+    def setUp(self):
+        super(RecomputeSolutionHitStatisticsTest, self).setUp()
+        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
+        self.state = self.exp.init_state_name
+
+        change_list = [{'cmd': exp_domain.CMD_RENAME_STATE,
+                        'old_state_name': self.state,
+                        'new_state_name': 'b'}]
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+
+        stats_models.SolutionHitEventLogEntryModel(
+            id='id1',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.SolutionHitEventLogEntryModel(
+            id='id2',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.SolutionHitEventLogEntryModel(
+            id='id3',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_2,
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.SolutionHitEventLogEntryModel(
+            id='id4',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_3,
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+        stats_models.SolutionHitEventLogEntryModel(
+            id='id1',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            time_spent_in_state_secs=1.0,
+            event_schema_version=1).put()
+        stats_models.SolutionHitEventLogEntryModel(
+            id='id5',
+            exp_id=self.exp_id,
+            exp_version=2,
+            state_name='b',
+            session_id=self.session_id_3,
+            time_spent_in_state_secs=1.0,
+            event_schema_version=2).put()
+
+        state_stats_dict = {
+            'total_answers_count_v1': 3,
+            'total_answers_count_v2': 9,
+            'useful_feedback_count_v1': 3,
+            'useful_feedback_count_v2': 9,
+            'total_hit_count_v1': 3,
+            'total_hit_count_v2': 0,
+            'first_hit_count_v1': 3,
+            'first_hit_count_v2': 0,
+            'num_times_solution_viewed_v2': 0,
+            'num_completions_v1': 3,
+            'num_completions_v2': 0
+        }
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0,
+            {
+                self.state: state_stats_dict
+            })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
+            {
+                'b': state_stats_dict
+            })
+
+    def test_standard_operation(self):
+        job_id = (
+            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, self.exp_version)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping[self.state]
+        self.assertEqual(state_stats['num_times_solution_viewed_v2'], 3)
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 2)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        state_stats = model.state_stats_mapping['b']
+        self.assertEqual(state_stats['num_times_solution_viewed_v2'], 4)
+
+
+class RecomputeActualStartStatisticsTest(test_utils.GenericTestBase):
+    exp_id = 'exp_id'
+    exp_version = 1
+    state = 'state_1'
+    session_id_1 = 'session_id_1'
+
+    def setUp(self):
+        super(RecomputeActualStartStatisticsTest, self).setUp()
+        self.save_new_default_exploration(self.exp_id, 'owner')
+
+        change_list = []
+        # Update exploration to version 3
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+
+        stats_models.ExplorationActualStartEventLogEntryModel(
+            id='id1',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            event_schema_version=2).put()
+        stats_models.ExplorationActualStartEventLogEntryModel(
+            id='id2',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            event_schema_version=2).put()
+        stats_models.ExplorationActualStartEventLogEntryModel(
+            id='id3',
+            exp_id=self.exp_id,
+            exp_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            event_schema_version=1).put()
+        stats_models.ExplorationActualStartEventLogEntryModel(
+            id='id4',
+            exp_id=self.exp_id,
+            exp_version=2,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            event_schema_version=2).put()
+
+        state_stats_dict = {
+            'total_answers_count_v1': 3,
+            'total_answers_count_v2': 9,
+            'useful_feedback_count_v1': 3,
+            'useful_feedback_count_v2': 9,
+            'total_hit_count_v1': 3,
+            'total_hit_count_v2': 0,
+            'first_hit_count_v1': 3,
+            'first_hit_count_v2': 0,
+            'num_times_solution_viewed_v2': 0,
+            'num_completions_v1': 3,
+            'num_completions_v2': 0
+        }
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0, {
+                self.state: state_stats_dict
+                })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 2, 0, 0, 0, 0, 0, 0, {
+                self.state: state_stats_dict
+                })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 3, 0, 0, 0, 0, 0, 0, {
+                self.state: state_stats_dict
+                })
+
+    def test_standard_operation(self):
+        job_id = (stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, self.exp_version)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        self.assertEqual(model.num_actual_starts_v2, 2)
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 2)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        self.assertEqual(model.num_actual_starts_v2, 3)
+
+    def test_recompute_for_version_with_no_events(self):
+        job_id = (stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 3)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        self.assertEqual(model.num_actual_starts_v2, 3)
+
+
+class RecomputeCompleteEventStatisticsTest(test_utils.GenericTestBase):
+    exp_id = 'exp_id'
+    exp_version = 1
+    state = 'state_1'
+    session_id_1 = 'session_id_1'
+
+    def setUp(self):
+        super(RecomputeCompleteEventStatisticsTest, self).setUp()
+        self.save_new_default_exploration(self.exp_id, 'owner')
+
+        change_list = []
+        exp_services.update_exploration(
+            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+
+        stats_models.CompleteExplorationEventLogEntryModel(
+            id='id1',
+            event_type=feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
+            exploration_id=self.exp_id,
+            exploration_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            client_time_spent_in_secs=1.0,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+        stats_models.CompleteExplorationEventLogEntryModel(
+            id='id2',
+            event_type=feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
+            exploration_id=self.exp_id,
+            exploration_version=self.exp_version,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            client_time_spent_in_secs=1.0,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+        stats_models.CompleteExplorationEventLogEntryModel(
+            id='id3',
+            event_type=feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
+            exploration_id=self.exp_id,
+            exploration_version=2,
+            state_name=self.state,
+            session_id=self.session_id_1,
+            client_time_spent_in_secs=1.0,
+            params={},
+            play_type=feconf.PLAY_TYPE_NORMAL,
+            event_schema_version=2).put()
+
+        state_stats_dict = {
+            'total_answers_count_v1': 3,
+            'total_answers_count_v2': 9,
+            'useful_feedback_count_v1': 3,
+            'useful_feedback_count_v2': 9,
+            'total_hit_count_v1': 3,
+            'total_hit_count_v2': 0,
+            'first_hit_count_v1': 3,
+            'first_hit_count_v2': 0,
+            'num_times_solution_viewed_v2': 0,
+            'num_completions_v1': 3,
+            'num_completions_v2': 0
+        }
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0, {
+                self.state: state_stats_dict
+                })
+        stats_models.ExplorationStatsModel.create(
+            self.exp_id, 2, 0, 0, 0, 0, 0, 0, {
+                self.state: state_stats_dict
+                })
+
+    def test_standard_operation(self):
+        job_id = (
+            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
+        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, self.exp_version)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        self.assertEqual(model.num_completions_v2, 2)
+
+        model_id = stats_models.ExplorationStatsModel.get_entity_id(
+            self.exp_id, 2)
+        model = stats_models.ExplorationStatsModel.get(model_id)
+        self.assertEqual(model.num_completions_v2, 3)
 
 
 class GenerateV1StatisticsJobTest(test_utils.GenericTestBase):