launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #00833
[Merge] lp:~lifeless/launchpad/oops into lp:launchpad/devel
Robert Collins has proposed merging lp:~lifeless/launchpad/oops into lp:launchpad/devel.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Create a more generic environment for accruing/reporting on external/blocking actions.
--
https://code.launchpad.net/~lifeless/launchpad/oops/+merge/34272
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/launchpad/oops into lp:launchpad/devel.
=== modified file 'lib/canonical/launchpad/webapp/adapter.py'
--- lib/canonical/launchpad/webapp/adapter.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/adapter.py 2010-09-01 05:06:01 +0000
@@ -6,6 +6,7 @@
__metaclass__ = type
+import datetime
import os
import re
import sys
@@ -22,6 +23,7 @@
ISOLATION_LEVEL_SERIALIZABLE,
QueryCanceledError,
)
+import pytz
from storm.database import register_scheme
from storm.databases.postgres import (
Postgres,
@@ -63,7 +65,12 @@
SLAVE_FLAVOR,
)
from canonical.launchpad.webapp.opstats import OpStats
-from canonical.lazr.utils import safe_hasattr
+from canonical.lazr.utils import get_current_browser_request, safe_hasattr
+from lp.services.timeline.timeline import Timeline
+from lp.services.timeline.requesttimeline import (
+ get_request_timeline,
+ set_request_timeline,
+ )
__all__ = [
@@ -80,6 +87,8 @@
]
+UTC = pytz.utc
+
classImplements(TimeoutError, IRequestExpired)
@@ -123,9 +132,9 @@
pass
def afterCompletion(self, txn):
- now = time()
- _log_statement(
- now, now, None, 'Transaction completed, status: %s' % txn.status)
+ action = get_request_timeline(get_current_browser_request()).start(
+ "SQL-nostore", 'Transaction completed, status: %s' % txn.status)
+ action.finish()
def set_request_started(
@@ -151,10 +160,10 @@
if starttime is None:
starttime = time()
_local.request_start_time = starttime
- if request_statements is None:
- _local.request_statements = []
- else:
- _local.request_statements = request_statements
+ if request_statements is not None:
+ # Requires poking at the API; default is to Just Work.
+ request = get_current_browser_request()
+ set_request_timeline(request, Timeline(request_statements))
_local.current_statement_timeout = None
_local.enable_timeout = enable_timeout
if txn is not None:
@@ -167,9 +176,9 @@
"""
if getattr(_local, 'request_start_time', None) is None:
warnings.warn('clear_request_started() called outside of a request')
-
_local.request_start_time = None
- _local.request_statements = []
+ request = get_current_browser_request()
+ set_request_timeline(request, Timeline())
commit_logger = getattr(_local, 'commit_logger', None)
if commit_logger is not None:
_local.commit_logger.txn.unregisterSynch(_local.commit_logger)
@@ -179,7 +188,8 @@
def summarize_requests():
"""Produce human-readable summary of requests issued so far."""
secs = get_request_duration()
- statements = getattr(_local, 'request_statements', [])
+ request = get_current_browser_request()
+ timeline = get_request_timeline(request)
from canonical.launchpad.webapp.errorlog import (
maybe_record_user_requested_oops)
oopsid = maybe_record_user_requested_oops()
@@ -187,14 +197,15 @@
oops_str = ""
else:
oops_str = " %s" % oopsid
- log = "%s queries issued in %.2f seconds%s" % (
- len(statements), secs, oops_str)
+ log = "%s queries/external actions issued in %.2f seconds%s" % (
+ len(timeline.actions), secs, oops_str)
return log
def store_sql_statements_and_request_duration(event):
+ actions = get_request_timeline(get_current_browser_request()).actions
event.request.setInWSGIEnvironment(
- 'launchpad.sqlstatements', len(get_request_statements()))
+ 'launchpad.nonpythonstatements', len(actions))
event.request.setInWSGIEnvironment(
'launchpad.requestduration', get_request_duration())
@@ -205,7 +216,16 @@
The list is composed of (starttime, endtime, db_id, statement) tuples.
Times are given in milliseconds since the start of the request.
"""
- return getattr(_local, 'request_statements', [])
+ result = []
+ request = get_current_browser_request()
+ for action in get_request_timeline(request).actions:
+ if not action.category.startswith("SQL-"):
+ continue
+ # Can't show incomplete requests in this API
+ if action.duration is None:
+ continue
+ result.append(action.log_tuple())
+ return result
def get_request_start_time():
@@ -224,29 +244,6 @@
return now - starttime
-def _log_statement(starttime, endtime, connection_wrapper, statement):
- """Log that a database statement was executed."""
- request_starttime = getattr(_local, 'request_start_time', None)
- if request_starttime is None:
- return
-
- # convert times to integer millisecond values
- starttime = int((starttime - request_starttime) * 1000)
- endtime = int((endtime - request_starttime) * 1000)
- # A string containing no whitespace that lets us identify which Store
- # is being used.
- if connection_wrapper is not None:
- database_identifier = connection_wrapper._database.name
- else:
- database_identifier = None
- _local.request_statements.append(
- (starttime, endtime, database_identifier, statement))
-
- # store the last executed statement as an attribute on the current
- # thread
- threading.currentThread().lp_last_sql_statement = statement
-
-
def _check_expired(timeout):
"""Checks whether the current request has passed the given timeout."""
if timeout is None or not getattr(_local, 'enable_timeout', True):
@@ -530,15 +527,23 @@
if self._debug_sql or self._debug_sql_extra:
sys.stderr.write(statement + "\n")
sys.stderr.write("-" * 70 + "\n")
-
- now = time()
- connection._lp_statement_start_time = now
+ # store the last executed statement as an attribute on the current
+ # thread
+ threading.currentThread().lp_last_sql_statement = statement
+ request_starttime = getattr(_local, 'request_start_time', None)
+ if request_starttime is None:
+ return
+ action = get_request_timeline(get_current_browser_request()).start(
+ 'SQL-%s' % connection._database.name, statement)
+ connection._lp_statement_action = action
def connection_raw_execute_success(self, connection, raw_cursor,
statement, params):
- end = time()
- start = getattr(connection, '_lp_statement_start_time', end)
- _log_statement(start, end, connection, statement)
+ action = getattr(connection, '_lp_statement_action', None)
+ if action is not None:
+ # action may be None if the tracer was installed the statement was
+ # submitted.
+ action.finish()
def connection_raw_execute_error(self, connection, raw_cursor,
statement, params, error):
=== modified file 'lib/canonical/launchpad/webapp/errorlog.py'
--- lib/canonical/launchpad/webapp/errorlog.py 2010-08-26 02:47:21 +0000
+++ lib/canonical/launchpad/webapp/errorlog.py 2010-09-01 05:06:01 +0000
@@ -31,7 +31,7 @@
from canonical.launchpad.layers import WebServiceLayer
from canonical.launchpad.webapp.adapter import (
get_request_duration,
- get_request_statements,
+ get_request_start_time,
soft_timeout_expired,
)
from canonical.launchpad.webapp.interfaces import (
@@ -42,7 +42,7 @@
from canonical.launchpad.webapp.opstats import OpStats
from canonical.lazr.utils import safe_hasattr
from lp.services.log.uniquefileallocator import UniqueFileAllocator
-
+from lp.services.timeline.requesttimeline import get_request_timeline
UTC = pytz.utc
@@ -451,11 +451,18 @@
strurl = _safestr(url)
duration = get_request_duration()
-
- statements = sorted(
- (start, end, _safestr(database_id), _safestr(statement))
- for (start, end, database_id, statement)
- in get_request_statements())
+ # In principle the timeline is per-request, but see bug=623199 -
+ # at this point the request is optional, but get_request_timeline
+ # does not care; when it starts caring, we will always have a
+ # request object (or some annotations containing object).
+ # RBC 20100901
+ timeline = get_request_timeline(request)
+ statements = []
+ for action in timeline.actions:
+ log_tuple = action.log_tuple()
+ statements.append(
+ log_tuple[:2] +
+ (_safestr(log_tuple[2]), _safestr(log_tuple[3])))
oopsid, filename = self.log_namer.newId(now)
=== modified file 'lib/canonical/launchpad/webapp/ftests/test_adapter.txt'
--- lib/canonical/launchpad/webapp/ftests/test_adapter.txt 2010-03-25 11:58:42 +0000
+++ lib/canonical/launchpad/webapp/ftests/test_adapter.txt 2010-09-01 05:06:01 +0000
@@ -9,6 +9,7 @@
>>> import threading
>>> import transaction
>>> from zope.component import getUtility
+ >>> from lazr.restful.utils import get_current_browser_request
>>> from storm.zope.interfaces import IZStorm
>>> from canonical.launchpad.webapp.interfaces import (
... IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
@@ -17,6 +18,7 @@
>>> from canonical.launchpad.webapp.adapter import (
... clear_request_started, get_request_statements,
... set_request_started)
+ >>> from lp.services.timeline.requesttimeline import get_request_timeline
There are several possible database connections available via the
IStoreSelector utility.
@@ -50,6 +52,16 @@
SELECT 1
SELECT 2
+A timeline is created too:
+
+ >>> for action in get_request_timeline(
+ ... get_current_browser_request()).actions:
+ ... if not action.category.startswith("SQL-"):
+ ... continue
+ ... print action.detail
+ SELECT 1
+ SELECT 2
+
After we complete the request, the statement log is cleared:
>>> clear_request_started()
=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py 2010-08-27 16:01:05 +0000
+++ lib/canonical/launchpad/webapp/servers.py 2010-09-01 05:06:01 +0000
@@ -977,7 +977,7 @@
request string (1st line of request)
response status
response bytes written
- number of sql statements
+ number of nonpython statements (sql, email, memcache, rabbit etc)
request duration
number of ticks during traversal
number of ticks during publication
@@ -998,7 +998,7 @@
bytes_written = task.bytes_written
userid = cgi_env.get('launchpad.userid', '')
pageid = cgi_env.get('launchpad.pageid', '')
- sql_statements = cgi_env.get('launchpad.sqlstatements', 0)
+ nonpython_statements = cgi_env.get('launchpad.nonpythonstatements', 0)
request_duration = cgi_env.get('launchpad.requestduration', 0)
traversal_ticks = cgi_env.get('launchpad.traversalticks', 0)
publication_ticks = cgi_env.get('launchpad.publicationticks', 0)
@@ -1016,7 +1016,7 @@
first_line,
status,
bytes_written,
- sql_statements,
+ nonpython_statements,
request_duration,
traversal_ticks,
publication_ticks,
=== modified file 'lib/canonical/launchpad/webapp/tests/test_publication.py'
--- lib/canonical/launchpad/webapp/tests/test_publication.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/tests/test_publication.py 2010-09-01 05:06:01 +0000
@@ -292,7 +292,8 @@
next_oops = error_reporting_utility.getLastOopsReport()
# Ensure the OOPS mentions the correct exception
- self.assertTrue(repr(next_oops).find("DisconnectionError") != -1)
+ self.assertTrue(repr(next_oops).find("DisconnectionError") != -1,
+ "next_oops was %r" % next_oops)
# Ensure the OOPS is correctly marked as informational only.
self.assertEqual(next_oops.informational, 'True')
=== modified file 'lib/canonical/launchpad/webapp/tests/test_user_requested_oops.py'
--- lib/canonical/launchpad/webapp/tests/test_user_requested_oops.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/tests/test_user_requested_oops.py 2010-09-01 05:06:01 +0000
@@ -4,7 +4,6 @@
__metaclass__ = type
-import unittest
from lazr.restful.utils import get_current_browser_request
from zope.component import getUtility
@@ -84,7 +83,3 @@
self.assertEqual(last_oops.type, 'UserRequestOops')
self.assertEqual(last_oops.informational, 'True')
-
-def test_suite():
- return unittest.TestLoader().loadTestsFromName(__name__)
-
=== added directory 'lib/lp/services/timeline'
=== added file 'lib/lp/services/timeline/__init__.py'
--- lib/lp/services/timeline/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/__init__.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,14 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""lp.services.timeline provides a timeline for varied actions.
+
+This is used as part of determining where time goes in a request.
+
+NOTE that it is not LP's timeline-view for products, though they are similar in
+intent and concept (If a better name presents itself, this package may be
+renamed).
+
+Because this part of lp.services, packages in this namespace can only use
+general LAZR or library functionality.
+"""
=== added file 'lib/lp/services/timeline/requesttimeline.py'
--- lib/lp/services/timeline/requesttimeline.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/requesttimeline.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,42 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Manage a Timeline for a request."""
+
+__all__ = ['get_request_timeline', 'set_request_timeline']
+
+__metaclass__ = type
+
+# XXX: undesirable but pragmatic. bug=623199 RBC 20100901
+from canonical.launchpad import webapp
+from timeline import Timeline
+
+
+def get_request_timeline(request):
+ """Get a Timeline for request.
+
+ This returns the request.annotations['timeline'], creating it if necessary.
+
+ :param request: A zope/launchpad request object.
+ :return: A lp.services.timeline.timeline.Timeline object for the request.
+ """
+ try:
+ return webapp.adapter._local.request_timeline
+ except AttributeError:
+ return set_request_timeline(request, Timeline())
+ # Disabled code path: bug 623199
+ return request.annotations.setdefault('timeline', Timeline())
+
+
+def set_request_timeline(request, timeline):
+ """Explicitly set a tiemline for request.
+
+ This is used by code which wants to manually assemble a timeline.
+
+ :param request: A zope/launchpad request object.
+ :param timeline: A Timeline.
+ """
+ webapp.adapter._local.request_timeline = timeline
+ return timeline
+ # Disabled code path: bug 623199
+ request.annotations['timeline'] = timeline
=== added directory 'lib/lp/services/timeline/tests'
=== added file 'lib/lp/services/timeline/tests/__init__.py'
--- lib/lp/services/timeline/tests/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/tests/__init__.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,4 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for lp.services.timeline."""
=== added file 'lib/lp/services/timeline/tests/test_requesttimeline.py'
--- lib/lp/services/timeline/tests/test_requesttimeline.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/tests/test_requesttimeline.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,56 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests of requesttimeline."""
+
+__metaclass__ = type
+
+import testtools
+from zope.publisher.browser import TestRequest
+
+from canonical.launchpad import webapp
+from lp.services.timeline.requesttimeline import (
+ get_request_timeline,
+ set_request_timeline,
+ )
+from lp.services.timeline.timeline import OverlappingActionError, Timeline
+
+
+class TestRequestTimeline(testtools.TestCase):
+
+ # These disabled tests are for the desired API using request annotations.
+ # bug=623199 describes some issues with why this doesn't work.
+ def disabled_test_new_request_get_request_timeline_works(self):
+ req = TestRequest()
+ timeline = get_request_timeline(req)
+ self.assertIsInstance(timeline, Timeline)
+
+ def disabled_test_same_timeline_repeated_calls(self):
+ req = TestRequest()
+ timeline = get_request_timeline(req)
+ self.assertEqual(timeline, get_request_timeline(req))
+
+ def disabled_test_set_timeline(self):
+ req = TestRequest()
+ timeline = Timeline()
+ set_request_timeline(req, timeline)
+ self.assertEqual(timeline, get_request_timeline(req))
+
+ # Tests while adapter._local contains the timeline --start---
+ def test_new_request_get_request_timeline_uses_webapp(self):
+ req = TestRequest()
+ timeline = get_request_timeline(req)
+ self.assertIsInstance(timeline, Timeline)
+ self.assertTrue(webapp.adapter._local.request_timeline is timeline)
+
+ def test_same_timeline_repeated_calls(self):
+ req = TestRequest()
+ timeline = get_request_timeline(req)
+ self.assertEqual(timeline, get_request_timeline(req))
+
+ def test_set_timeline(self):
+ req = TestRequest()
+ timeline = Timeline()
+ set_request_timeline(req, timeline)
+ self.assertEqual(timeline, get_request_timeline(req))
+ # Tests while adapter._local contains the timeline ---end---
=== added file 'lib/lp/services/timeline/tests/test_timedaction.py'
--- lib/lp/services/timeline/tests/test_timedaction.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/tests/test_timedaction.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,47 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests of the TimedAction class."""
+
+__metaclass__ = type
+
+import datetime
+
+import testtools
+
+from lp.services.timeline.timedaction import TimedAction
+from lp.services.timeline.timeline import Timeline
+
+
+class TestTimedAction(testtools.TestCase):
+
+ def test_starts_now(self):
+ action = TimedAction("Sending mail", None)
+ self.assertIsInstance(action.start, datetime.datetime)
+
+ def test_finish_sets_duration(self):
+ action = TimedAction("Sending mail", None)
+ self.assertEqual(None, action.duration)
+ action.finish()
+ self.assertIsInstance(action.duration, datetime.timedelta)
+
+ def test__init__sets_category(self):
+ action = TimedAction("Sending mail", None)
+ self.assertEqual("Sending mail", action.category)
+
+ def test__init__sets_detail(self):
+ action = TimedAction(None, "fred.jones@xxxxxxxxxxx")
+ self.assertEqual("fred.jones@xxxxxxxxxxx", action.detail)
+
+ def test_log_tuple(self):
+ timeline = Timeline()
+ action = TimedAction("foo", "bar", timeline)
+ # Set variable for deterministic results
+ action.start = timeline.baseline + datetime.timedelta(0, 0, 0, 2)
+ action.duration = datetime.timedelta(0, 0, 0, 4)
+ log_tuple = action.log_tuple()
+ self.assertEqual(4, len(log_tuple), "!= 4 elements %s" % (log_tuple,))
+ self.assertAlmostEqual(2, log_tuple[0])
+ self.assertAlmostEqual(4, log_tuple[1])
+ self.assertEqual("foo", log_tuple[2])
+ self.assertEqual("bar", log_tuple[3])
=== added file 'lib/lp/services/timeline/tests/test_timeline.py'
--- lib/lp/services/timeline/tests/test_timeline.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/tests/test_timeline.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,50 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests of the Timeline class."""
+
+__metaclass__ = type
+
+import datetime
+
+import testtools
+
+from lp.services.timeline.timedaction import TimedAction
+from lp.services.timeline.timeline import OverlappingActionError, Timeline
+
+
+class TestTimeline(testtools.TestCase):
+
+ def test_start_returns_action(self):
+ timeline = Timeline()
+ action = timeline.start("Sending mail", "Noone")
+ self.assertIsInstance(action, TimedAction)
+ self.assertEqual("Sending mail", action.category)
+ self.assertEqual("Noone", action.detail)
+ self.assertEqual(None, action.duration)
+ self.assertEqual(timeline, action.timeline)
+
+ def test_can_supply_list(self):
+ actions = "foo"
+ timeline = Timeline(actions)
+ self.assertEqual(actions, timeline.actions)
+
+ def test_start_with_unfinished_action_fails(self):
+ # A design constraint of timeline says that overlapping actions are not
+ # permitted. See the Timeline docstrings.
+ timeline = Timeline()
+ action = timeline.start("Sending mail", "Noone")
+ self.assertRaises(OverlappingActionError, timeline.start,
+ "Sending mail", "Noone")
+
+ def test_start_after_finish_works(self):
+ timeline = Timeline()
+ action = timeline.start("Sending mail", "Noone")
+ action.finish()
+ action = timeline.start("Sending mail", "Noone")
+ action.finish()
+ self.assertEqual(2, len(timeline.actions))
+
+ def test_baseline(self):
+ timeline = Timeline()
+ self.assertIsInstance(timeline.baseline, datetime.datetime)
=== added file 'lib/lp/services/timeline/timedaction.py'
--- lib/lp/services/timeline/timedaction.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/timedaction.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,59 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Time a single categorised action."""
+
+
+__all__ = ['TimedAction']
+
+__metaclass__ = type
+
+
+import datetime
+
+import pytz
+
+
+UTC = pytz.utc
+
+
+class TimedAction:
+ """An individual action which has been timed.
+
+ :ivar timeline: The timeline that this action took place within.
+ :ivar start: A datetime object with tz for the start of the action.
+ :ivar duration: A timedelta for the duration of the action. None for
+ actions which have not completed.
+ :ivar category: The category of the action. E.g. "sql".
+ :ivar detail: The detail about the action. E.g. "SELECT COUNT(*) ..."
+ """
+
+ def __init__(self, category, detail, timeline=None):
+ """Create a TimedAction.
+
+ New TimedActions have a start but no duration.
+
+ :param category: The category for the action.
+ :param detail: The detail about the action being timed.
+ :param timeline: The timeline for the action.
+ """
+ self.start = datetime.datetime.now(UTC)
+ self.duration = None
+ self.category = category
+ self.detail = detail
+ self.timeline = timeline
+
+ def log_tuple(self):
+ """Return a 4-tuple suitable for errorlog's use."""
+ offset = self._td_to_ms(self.start - self.timeline.baseline)
+ length = self._td_to_ms(self.duration)
+ return (offset, length, self.category, self.detail)
+
+ def _td_to_ms(self, td):
+ """Tweak on a backport from python 2.7"""
+ return (td.microseconds + (
+ td.seconds + td.days * 24 * 3600) * 10**6) / 10**3
+
+ def finish(self):
+ """Mark the TimedAction as finished."""
+ self.duration = datetime.datetime.now(UTC) - self.start
=== added file 'lib/lp/services/timeline/timeline.py'
--- lib/lp/services/timeline/timeline.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/timeline/timeline.py 2010-09-01 05:06:01 +0000
@@ -0,0 +1,62 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Coordinate a sequence of non overlapping TimedActionss."""
+
+__all__ = ['Timeline']
+
+__metaclass__ = type
+
+import datetime
+
+from pytz import utc as UTC
+
+from timedaction import TimedAction
+
+
+class OverlappingActionError(Exception):
+ """A new action was attempted without finishing the prior one."""
+ # To make analysis easy we do not permit overlapping actions: each
+ # action that is being timed and accrued must complete before the next
+ # is started. This means, for instance, that sending mail cannot do SQL
+ # queries, as both are timed and accrued. OTOH it makes analysis and
+ # serialisation of timelines simpler, and for the current use cases in
+ # Launchpad this is sufficient. This constraint should not be considered
+ # sancrosant - if, in future, we desire timelines with overlapping actions,
+ # as long as the OOPS analysis code is extended to generate sensible
+ # reports in those situations, this can be changed.
+
+
+class Timeline:
+ """A sequence of TimedActions.
+
+ This is used for collecting expensive/external actions inside Launchpad
+ requests.
+
+ :ivar actions: The actions.
+ :ivar baseline: The point the timeline starts at.
+ """
+
+ def __init__(self, actions=None):
+ """Create a Timeline.
+
+ :param actions: An optional object to use to store the timeline. This
+ must implement the list protocol.
+ """
+ if actions is None:
+ actions = []
+ self.actions = actions
+ self.baseline = datetime.datetime.now(UTC)
+
+ def start(self, category, detail):
+ """Create a new TimedAction at the end of the timeline.
+
+ :param category: the category for the action.
+ :param detail: The detail for the action.
+ :return: A TimedAction for that category and detail.
+ """
+ result = TimedAction(category, detail, self)
+ if self.actions and self.actions[-1].duration is None:
+ raise OverlappingActionError(self.actions[-1], result)
+ self.actions.append(result)
+ return result