Milimetric has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/79429


Change subject: re-organizing the return value of report nodes
......................................................................

re-organizing the return value of report nodes

Change-Id: Iea50bfc69841e9efe530fb1d92be27ffa8d20690
---
M tests/fixtures.py
M tests/test_models/test_report_response.py
M wikimetrics/controllers/reports.py
M wikimetrics/models/persistent_report.py
M wikimetrics/models/report_nodes/aggregate_report.py
D wikimetrics/models/report_nodes/concat_metrics_report.py
M wikimetrics/models/report_nodes/multi_project_metric_report.py
M wikimetrics/models/report_nodes/report.py
M wikimetrics/models/report_nodes/run_report.py
M wikimetrics/run.py
10 files changed, 192 insertions(+), 173 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/analytics/wikimetrics 
refs/changes/29/79429/1

diff --git a/tests/fixtures.py b/tests/fixtures.py
index 4f3da64..b24a02e 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -252,25 +252,25 @@
         report_created = PersistentReport(
             user_id=web_test_user.id,
             status=celery.states.PENDING,
-            result_key=None,
+            queue_result_key=None,
             show_in_ui=True
         )
         report_started = PersistentReport(
             user_id=web_test_user.id,
             status=celery.states.STARTED,
-            result_key=None,
+            queue_result_key=None,
             show_in_ui=True
         )
         report_started2 = PersistentReport(
             user_id=web_test_user.id,
             status=celery.states.STARTED,
-            result_key=None,
+            queue_result_key=None,
             show_in_ui=True
         )
         report_finished = PersistentReport(
             user_id=web_test_user.id,
             status=celery.states.SUCCESS,
-            result_key=None,
+            queue_result_key=None,
             show_in_ui=True
         )
         self.session.add_all([
diff --git a/tests/test_models/test_report_response.py 
b/tests/test_models/test_report_response.py
index 254ba56..f356b62 100644
--- a/tests/test_models/test_report_response.py
+++ b/tests/test_models/test_report_response.py
@@ -1,7 +1,10 @@
-from wikimetrics.models import Cohort, RunReport, MultiProjectMetricReport, 
Aggregation
+from nose.tools import assert_equals, assert_true
+from celery.exceptions import SoftTimeLimitExceeded
+from wikimetrics.models import (
+    Cohort, RunReport, MultiProjectMetricReport, Aggregation, PersistentReport
+)
 from wikimetrics.metrics import NamespaceEdits
 from ..fixtures import QueueDatabaseTest
-from nose.tools import assert_equals, assert_true
 
 
 class RunReportTest(QueueDatabaseTest):
@@ -20,7 +23,7 @@
             },
             'metric': {
                 'name': 'NamespaceEdits',
-                'namespaces': [0,1,2],
+                'namespaces': [0, 1, 2],
                 'start_date': '2013-06-01',
                 'end_date': '2013-09-01',
                 'individualResults': True,
@@ -31,7 +34,12 @@
             },
         }]
         jr = RunReport(desired_responses, user_id=self.test_user_id)
-        results = jr.task.delay(jr).get()[0]
+        results = jr.task.delay(jr).get()
+        result_key = self.session.query(PersistentReport)\
+            .filter(PersistentReport.id == jr.children[0].persistent_id)\
+            .one()\
+            .result_key
+        results = results[result_key]
         # TODO: figure out why one of the resulting wiki_user_ids is None here
         assert_equals(
             results[Aggregation.IND][0][self.test_mediawiki_user_id]['edits'],
@@ -46,7 +54,7 @@
             },
             'metric': {
                 'name': 'NamespaceEdits',
-                'namespaces': [0,1,2],
+                'namespaces': [0, 1, 2],
                 'start_date': '2013-06-01',
                 'end_date': '2013-09-01',
                 'individualResults': True,
@@ -57,7 +65,12 @@
             },
         }]
         jr = RunReport(desired_responses, user_id=self.test_user_id)
-        results = jr.task.delay(jr).get()[0]
+        results = jr.task.delay(jr).get()
+        result_key = self.session.query(PersistentReport)\
+            .filter(PersistentReport.id == jr.children[0].persistent_id)\
+            .one()\
+            .result_key
+        results = results[result_key]
         assert_equals(
             results[Aggregation.IND][0][self.test_mediawiki_user_id]['edits'],
             2,
@@ -76,7 +89,7 @@
             },
             'metric': {
                 'name': 'BytesAdded',
-                'namespaces': [0,1,2],
+                'namespaces': [0, 1, 2],
                 'start_date': '2013-06-01',
                 'end_date': '2013-09-01',
                 'individualResults': True,
@@ -87,8 +100,12 @@
             },
         }]
         jr = RunReport(desired_responses, user_id=self.test_user_id)
-        results = jr.task.delay(jr).get()[0]
-        print(results)
+        results = jr.task.delay(jr).get()
+        result_key = self.session.query(PersistentReport)\
+            .filter(PersistentReport.id == jr.children[0].persistent_id)\
+            .one()\
+            .result_key
+        results = results[result_key]
         assert_equals(
             
results[Aggregation.IND][0][self.test_mediawiki_user_id]['net_sum'],
             6,
@@ -101,39 +118,48 @@
     
     # TODO: figure out how to write this test properly,
     # basically: how to make sure that the queue can be hamerred with requests
-    #def test_lots_of_concurrent_requests(self):
-        #desired_responses = [{
-            #'name': 'Edits - test',
-            #'cohort': {
-                #'id': self.test_cohort_id,
-            #},
-            #'metric': {
-                #'name': 'BytesAdded',
-                #'namespaces': '0,1,2',
-                #'start_date': '2013-06-01',
-                #'end_date': '2013-09-01',
-                #'individualResults': True,
-                #'aggregateResults': True,
-                #'aggregateSum': True,
-                #'aggregateAverage': False,
-                #'aggregateStandardDeviation': False,
-            #},
-        #}]
-        #reports = []
-        ## NOTE: you can make this loop as much as you'd like if celery
-        ## is allowed enough concurrent workers, set via CELERYD_CONCURRENCY
-        #for i in range(6):
-            #jr = RunReport(desired_responses, user_id=self.test_user_id)
-            #reports.append(jr.task.delay(jr))
+    def test_lots_of_concurrent_requests(self):
+        desired_responses = [{
+            'name': 'Edits - test',
+            'cohort': {
+                'id': self.test_cohort_id,
+            },
+            'metric': {
+                'name': 'BytesAdded',
+                'namespaces': '0,1,2',
+                'start_date': '2013-06-01',
+                'end_date': '2013-09-01',
+                'individualResults': True,
+                'aggregateResults': True,
+                'aggregateSum': True,
+                'aggregateAverage': False,
+                'aggregateStandardDeviation': False,
+            },
+        }]
+        reports = []
+        # NOTE: you can make this loop as much as you'd like if celery
+        # is allowed enough concurrent workers, set via CELERYD_CONCURRENCY
+        trials = 3
+        for i in range(trials):
+            jr = RunReport(desired_responses, user_id=self.test_user_id)
+            reports.append((jr, jr.task.delay(jr)))
         
-        #successes = 0
-        #for report in reports:
-            #try:
-                #results = report.get()[0]
-                #if results[Aggregation.SUM]['positive_only_sum'] == 50:
-                    #successes += 1
-            #except:
-                #print('timeout expired for this task')
+        successes = 0
+        for jr, delayed in reports:
+            try:
+                results = delayed.get()
+                result_key = self.session.query(PersistentReport)\
+                    .filter(PersistentReport.id == 
jr.children[0].persistent_id)\
+                    .one()\
+                    .result_key
+                results = results[result_key]
+                if results[Aggregation.SUM]['positive_only_sum'] == 150:
+                    successes += 1
+            except SoftTimeLimitExceeded:
+                print('Timeout expired during this task.')
+            except Exception, e:
+                print('An exception occurred during this task.')
+                raise
         
-        #print('Successes: {0}'.format(successes))
-        #assert_true(successes > 3, 'at least half of the tasks succeeded')
+        print('Successes: {0}'.format(successes))
+        assert_true(successes == trials, 'all of the trials must succeed')
diff --git a/wikimetrics/controllers/reports.py 
b/wikimetrics/controllers/reports.py
index ccce0a0..7f01ee3 100644
--- a/wikimetrics/controllers/reports.py
+++ b/wikimetrics/controllers/reports.py
@@ -62,20 +62,40 @@
     return reports_json
 
 
[email protected]('/reports/status/<task_id>')
-def report_status(task_id):
-    celery_task = Report.task.AsyncResult(task_id)
+def get_celery_task(result_key):
+    """
+    From a unique identifier, gets the celery task and database records 
associated.
+    
+    Parameters
+        result_key  : The unique identifier found in the report database table
+    
+    Returns
+        A tuple of the form (celery_task_object, database_report_object)
+    """
+    db_session = db.get_session()
+    pj = db_session.query(PersistentReport)\
+        .filter(PersistentReport.result_key == result_key)\
+        .one()
+    
+    celery_task = Report.task.AsyncResult(pj.queue_result_key)
+    db_session.close()
+    return (celery_task, pj)
+
+
[email protected]('/reports/status/<result_key>')
+def report_status(result_key):
+    celery_task, pj = get_celery_task(result_key)
     return json_response(status=celery_task.status)
 
 
[email protected]('/reports/result/<task_id>.csv')
-def report_result_csv(task_id):
-    celery_task = Report.task.AsyncResult(task_id)
[email protected]('/reports/result/<result_key>.csv')
+def report_result_csv(result_key):
+    celery_task, pj = get_celery_task(result_key)
     if not celery_task:
-        return json_error('no task exists with id: {0}'.format(task_id))
+        return json_error('no task exists with id: {0}'.format(result_key))
     
     if celery_task.ready():
-        task_result = celery_task.get()
+        task_result = celery_task.get()[result_key]
         
         csv_io = StringIO()
         if task_result:
@@ -130,38 +150,30 @@
         return json_response(status=celery_task.status)
 
 
[email protected]('/reports/result/<task_id>.json')
-def report_result_json(task_id):
-    celery_task = Report.task.AsyncResult(task_id)
[email protected]('/reports/result/<result_key>.json')
+def report_result_json(result_key):
+    celery_task, pj = get_celery_task(result_key)
     if not celery_task:
-        return json_error('no task exists with id: {0}'.format(task_id))
+        return json_error('no task exists with id: {0}'.format(result_key))
     
     if celery_task.ready():
-        task_result = celery_task.get()
-        
-        # get the parameters from the database
-        db_session = db.get_session()
-        report = db_session.query(PersistentReport)\
-            .filter(PersistentReport.result_key == task_id)\
-            .one()
-        parameters = report.parameters
-        db_session.close()
+        task_result = celery_task.get()[result_key]
         
         return json_response(
             result=task_result,
-            parameters=json.loads(parameters),
+            parameters=json.loads(pj.parameters),
         )
     else:
         return json_response(status=celery_task.status)
 
 
[email protected]('/reports/kill/<task_id>')
-def report_kill(task_id):
[email protected]('/reports/kill/<result_key>')
+def report_kill(result_key):
     return 'not implemented'
     #db_session = db.get_session()
-    #db_report = db_session.query(PersistentReport).get(task_id)
+    #db_report = db_session.query(PersistentReport).get(result_key)
     #if not db_report:
-        #return json_error('no task exists with id: {0}'.format(task_id))
+        #return json_error('no task exists with id: {0}'.format(result_key))
     #celery_task = Report.task.AsyncResult(db_report.result_key)
     #app.logger.debug('revoking task: %s', celery_task.id)
     #celery_task.revoke()
diff --git a/wikimetrics/models/persistent_report.py 
b/wikimetrics/models/persistent_report.py
index f08fa79..c2b86d0 100644
--- a/wikimetrics/models/persistent_report.py
+++ b/wikimetrics/models/persistent_report.py
@@ -18,6 +18,7 @@
     id = Column(Integer, primary_key=True)
     created = Column(DateTime, default=func.now())
     user_id = Column(Integer)
+    queue_result_key = Column(String(50))
     result_key = Column(String(50))
     status = Column(String(50))
     name = Column(String(2000))
@@ -25,16 +26,14 @@
     parameters = Column(String(4000))
     
     def update_status(self):
-        # if we don't have the result key leave as is (PENDING)
-        if self.result_key and self.status not in (celery.states.READY_STATES):
+        # if we don't have the result key leave as is
+        if self.queue_result_key and self.status not in 
(celery.states.READY_STATES):
             # TODO: inline import.  Can't import up above because of circular 
reference
             from wikimetrics.models.report_nodes import Report
-            celery_task = Report.task.AsyncResult(self.result_key)
+            celery_task = Report.task.AsyncResult(self.queue_result_key)
             self.status = celery_task.status
             existing_session = Session.object_session(self)
             if not existing_session:
                 existing_session = db.get_session()
                 existing_session.add(self)
             existing_session.commit()
-            # if the result is still an AsyncResult, leave it as PENDING
-            #if isinstance(celery_task.result, AsyncResult):
diff --git a/wikimetrics/models/report_nodes/aggregate_report.py 
b/wikimetrics/models/report_nodes/aggregate_report.py
index 990252a..2a5586c 100644
--- a/wikimetrics/models/report_nodes/aggregate_report.py
+++ b/wikimetrics/models/report_nodes/aggregate_report.py
@@ -62,30 +62,33 @@
             name=self.name,
         )]
     
-    def finish(self, multi_project_results):
+    def finish(self, result_dicts):
         aggregated_results = dict()
+        result_values = [r.values() for r in result_dicts]
+        child_results = [result for sublist in result_values for result in 
sublist]
         
         if self.aggregate:
             if self.aggregate_sum:
                 aggregated_results[Aggregation.SUM] = self.calculate(
-                    multi_project_results,
+                    child_results,
                     Aggregation.SUM
                 )
             if self.aggregate_average:
                 aggregated_results[Aggregation.AVG] = self.calculate(
-                    multi_project_results,
+                    child_results,
                     Aggregation.AVG
                 )
             if self.aggregate_std_deviation:
                 aggregated_results[Aggregation.STD] = self.calculate(
-                    multi_project_results,
+                    child_results,
                     Aggregation.STD
                 )
         
         if self.individual:
-            aggregated_results[Aggregation.IND] = multi_project_results
+            aggregated_results[Aggregation.IND] = child_results
         
-        return aggregated_results
+        result = self.report_result(aggregated_results, 
child_results=result_dicts)
+        return result
     
     def calculate(self, list_of_results, type_of_aggregate):
         # TODO: terrible redo this
diff --git a/wikimetrics/models/report_nodes/concat_metrics_report.py 
b/wikimetrics/models/report_nodes/concat_metrics_report.py
deleted file mode 100644
index 01fe064..0000000
--- a/wikimetrics/models/report_nodes/concat_metrics_report.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import report
-import pprint
-from wikimetrics.configurables import queue
-from metric_report import MetricReport
-
-
-__all__ = ['ConcatMetricsReport']
-
-
-class ConcatMetricsReport(report.ReportNode):
-    """
-    Report which runs several metrics on the same cohort and then
-    joins together the results from each metric into a suitable
-    2-D representation.
-    """
-    
-    def __init__(self, cohort, metrics):
-        super(ConcatMetricsReport, self).__init__()
-        self.cohort = cohort
-        self.metrics = metrics
-        # TODO enforce children always have a run @queue.task
-        self.children = [MetricReport(cohort, metric) for metric in metrics]
-        # TODO self.save()
-    
-    def finish(query_results):
-        # we're done - record result
-        for result in query_results:
-            pprint.pprint(result)
-    
-    def __repr__(self):
-        return '<ConcatMetricsReport("{0}")>'.format(self.persistent_id)
diff --git a/wikimetrics/models/report_nodes/multi_project_metric_report.py 
b/wikimetrics/models/report_nodes/multi_project_metric_report.py
index e0fdac0..e575a87 100644
--- a/wikimetrics/models/report_nodes/multi_project_metric_report.py
+++ b/wikimetrics/models/report_nodes/multi_project_metric_report.py
@@ -32,15 +32,14 @@
             # note that user_ids is actually just an iterator
             self.children.append(MetricReport(metric, user_ids, project))
     
-    def finish(self, query_results):
-        merged = {}
-        for res in query_results:
-            try:
-                merged.update(res)
-            except:
-                task_logger.error('updating failed: %s', res)
-                raise
-        return merged
+    def finish(self, metric_results):
+        merged_individual_results = {}
+        # TODO: handle collisions where the same ID is used accross projects
+        for res in metric_results:
+            merged_individual_results.update(res)
+        
+        result = self.report_result(merged_individual_results)
+        return result
     
     def __repr__(self):
         return '<MultiProjectMetricReport("{0}")>'.format(self.persistent_id)
diff --git a/wikimetrics/models/report_nodes/report.py 
b/wikimetrics/models/report_nodes/report.py
index 96a673a..1ff1f40 100644
--- a/wikimetrics/models/report_nodes/report.py
+++ b/wikimetrics/models/report_nodes/report.py
@@ -1,5 +1,6 @@
 import time
 import celery
+from uuid import uuid4
 from celery import group, chord, current_task
 from celery.result import AsyncResult
 from celery.exceptions import SoftTimeLimitExceeded
@@ -38,39 +39,12 @@
 
 @queue.task()
 def queue_task(report):
-    if not isinstance(report, ReportNode):
-        report.set_status(celery.states.STARTED, 
task_id=current_task.request.id)
     
     task_logger.info('running {0} on celery as {1}'.format(
         report,
         current_task.request.id,
     ))
-    result = report.run()
-    return result
-
-
[email protected]()
-def queue_finish_task(results, report):
-    """
-    This is the task which is executed after all of the child tasks
-    in a ReportNode have been executed.  It serves as a wrapper to the
-    finish() method which actually deals with child task results.
-    Note that the signature of this method is a little funny due to
-    a hack to get around the way that celery handles instance method tasks.
-    The ReportNode instance (report) is specified  when the callback
-    subtask is created, and the results argument is filled in by celery
-    once they have completed.  The order is just reversed because celery
-    is hardcoded to prepend the results from a chord into the argument list
-    specified when creating the subtask.
-    """
-    report.set_status(celery.states.STARTED, task_id=current_task.request.id)
-    
-    task_logger.info('finishing {0} on celery as {1}'.format(
-        report,
-        current_task.request.id,
-    ))
-    result = report.finish(results)
-    return result
+    return report.run()
 
 
 class Report(object):
@@ -82,7 +56,7 @@
                  user_id=None,
                  status=celery.states.PENDING,
                  name=None,
-                 result_key=None,
+                 queue_result_key=None,
                  children=[],
                  parameters='{}'):
         
@@ -98,11 +72,11 @@
         
         self.status = status
         self.name = name
-        self.result_key = result_key
+        self.queue_result_key = queue_result_key
         self.children = children
         
         # store report to database
-        # note that result_key is always empty at this stage
+        # note that queue_result_key is always empty at this stage
         pj = PersistentReport(user_id=self.user_id,
                               status=self.status,
                               name=self.name,
@@ -117,7 +91,7 @@
     def __repr__(self):
         return '<Report("{0}")>'.format(self.persistent_id)
     
-    def set_status(self, status, task_id):
+    def set_status(self, status, task_id=None):
         """
         helper function for updating database status after celery
         task has been started
@@ -125,7 +99,8 @@
         db_session = db.get_session()
         pj = db_session.query(PersistentReport).get(self.persistent_id)
         pj.status = status
-        pj.result_key = task_id
+        if task_id:
+            pj.queue_result_key = task_id
         db_session.add(pj)
         db_session.commit()
         db_session.close()
@@ -139,28 +114,67 @@
 
 class ReportNode(Report):
     
-    finish_task = queue_finish_task
-    
     def run(self):
+        """
+        This specialized version of run first runs all the children, then
+        calls the finish method with the results.
+        
+        NOTE: this used to spawn a tree of celery tasks.  That sacrificed 
parallelism
+        at the user level and gained parallelism at the task level.  That was 
bad.
+        So now this just runs all the children's run methods, collects the 
results,
+        and passes them to the finish method.  Deadlocking and celery worker 
starvation
+        are *much* less likely now.  Thank you Ori :)
+        """
+        self.set_status(celery.states.STARTED, task_id=current_task.request.id)
+        results = []
+        
         if self.children:
-            callback = queue_finish_task.s(self)
-            header = [queue_task.s(child) for child in self.children]
-            children_then_finish = chord(header)(callback)
             try:
-                return children_then_finish.get()
+                child_results = [child.run() for child in self.children]
+                results = self.finish(child_results)
             except SoftTimeLimitExceeded:
+                self.set_status(celery.states.FAILURE)
                 task_logger.error('timeout exceeded for {0}'.format(
                     current_task.request.id
                 ))
                 raise
-        else:
-            return []
+        
+        self.set_status(celery.states.SUCCESS)
+        return results
     
     def finish(self, results):
         """
-        Each ReportNode sublcass should implement this method to
-        deal with the results of its child reports"""
+        Each ReportNode sublcass should implement this method to deal with
+        the results of its child reports.  As a standard, report_results should
+        be called at the end of ReportNode.finish implementations.
+        """
         pass
+    
+    def report_result(self, results, child_results=[]):
+        """
+        Creates a unique identifier for this ReportNode, and returns a one 
element
+        dictionary with that identifier as the key and its results as the 
value.
+        This allows ReportNode results to be merged as the tree of ReportNodes 
is
+        evaluated.
+        
+        Parameters
+            results         : Anything that the ReportNode compiles in its 
finish step
+            child_results   : The results from a child Report(s) if they 
should be
+                              preserved.  ReportLeaf results and any 
ReportNode results
+                              that are copied should not be preserved.
+        """
+        self.result_key = str(uuid4())
+        db_session = db.get_session()
+        pj = db_session.query(PersistentReport).get(self.persistent_id)
+        pj.result_key = self.result_key
+        db_session.add(pj)
+        db_session.commit()
+        db_session.close()
+        
+        merged = {self.result_key: results}
+        for child_result in child_results:
+            merged.update(child_result)
+        return merged
 
 
 class ReportLeaf(Report):
diff --git a/wikimetrics/models/report_nodes/run_report.py 
b/wikimetrics/models/report_nodes/run_report.py
index cc584c5..8328399 100644
--- a/wikimetrics/models/report_nodes/run_report.py
+++ b/wikimetrics/models/report_nodes/run_report.py
@@ -84,8 +84,9 @@
         self.name = ', '.join(metric_names) + ' for ' + ', '.join(cohort_names)
         self.children = children
     
-    def finish(self, aggregated_output):
-        return aggregated_output
+    def finish(self, aggregated_results):
+        result = self.report_result('Finished', 
child_results=aggregated_results)
+        return result
     
     def __repr__(self):
         return '<RunReport("{0}")>'.format(self.persistent_id)
diff --git a/wikimetrics/run.py b/wikimetrics/run.py
index ba75d37..d56e569 100644
--- a/wikimetrics/run.py
+++ b/wikimetrics/run.py
@@ -22,10 +22,6 @@
 
 def run_celery():
     from configurables import queue
-    from .models import ConcatMetricsReport
-    from .models import MultiProjectMetricReport
-    from .models import MetricReport
-    from .models import RunReport
     queue.start(argv=['celery', 'worker', '-l', 'DEBUG'])
 
 

-- 
To view, visit https://gerrit.wikimedia.org/r/79429
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: Iea50bfc69841e9efe530fb1d92be27ffa8d20690
Gerrit-PatchSet: 1
Gerrit-Project: analytics/wikimetrics
Gerrit-Branch: master
Gerrit-Owner: Milimetric <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to