Merge lp:~kissiel/checkbox/suspend-v7 into lp:checkbox

Proposed by Maciej Kisielewski
Status: Work in progress
Proposed branch: lp:~kissiel/checkbox/suspend-v7
Merge into: lp:checkbox
Diff against target: 497 lines (+331/-11)
4 files modified
plainbox/plainbox/impl/session/resume.py (+33/-8)
plainbox/plainbox/impl/session/state.py (+27/-2)
plainbox/plainbox/impl/session/suspend.py (+125/-1)
plainbox/plainbox/impl/session/test_suspend.py (+146/-0)
To merge this branch: bzr merge lp:~kissiel/checkbox/suspend-v7
Reviewer Review Type Date Requested Status
Sylvain Pineau Needs Fixing
Zygmunt Krynicki (community) Needs Fixing
Review via email: mp+279128@code.launchpad.net

Description of the change

This MR brings v7 of session information.

v7 contains two additions:
 - timestamp of when the session was created
 - list of the test plan IDs that were selected during session creation

Note that this MR doesn't update any of the applications to use v7.

94a4203 plainbox:session: add 7th suspend format
b5f87a3 plainbox:session:resume: fix PEP-8 issues
9c0db7c plainbox:session:resume: bump date in header

To post a comment you must log in.
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

16:53 <@zyga> kissiel: -1 on posix timestamp, let's use something like datetime object
16:53 <@zyga> kissiel: I'm somewhat split on sessionstate, I'll explain why in a sec
16:54 <@zyga> kissiel: let's store more than one "timestamp": created / finished / updated -- created is fixed, finished is also fixed (when submitting) and
              last updated is changed each time we do stuff
16:55 <@zyga> kissiel: a single timestamp is less useful IMHO
16:55 * zyga wonders where is the patch that made SessionState a POD
16:55 <@zyga> kissiel: the UI should look at "updated" time stamps as that's what the user understands
16:55 <@zyga> kissiel: but the rest should be useful too
16:56 <@zyga> kissiel: I'd like to also see patches that make use fo the new field where it makes sense to do so (SA APIs)

I'm also somewhat split on adding more stuff to SessionState. We have the unfortunate split between SessionManager (global state), SesesionDeviceContext (device state) that contains a SessionState (which is the only thing that gets serialized). I'd like to see a follow-up where SessionManager is serialized instead. Storing global test plan list and per-device SessionState (which can later on just merge with SessionDeviceContext, the only reason that is separate was lack of SessionAssistant and difficulty of changing without breaking apps).

review: Needs Fixing
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

16:56 <@zyga> kissiel: I'd like to also see patches that make use fo the new field where it makes sense to do so (SA APIs)
16:57 < kissiel> zyga: I used datetime at first go, but this required custom endcoders and decoders
16:57 < kissiel> this is why I changed that to posix
16:58 <@zyga> kissiel: that's okay, we can handle that on POD assignment
16:59 <@zyga> kissiel: timestamp is infinitely more useful
16:59 <@zyga> kissiel: when it carries a human readable time concept
16:59 <@zyga> kissiel: not when it carries a number
16:59 < kissiel> zyga: so were should the parsing happen?
16:59 < kissiel> zyga: in your opinion?
16:59 <@zyga> kissiel: you can fix that in one place, in the resume code that deserializes session meta-data
16:59 <@zyga> kissiel: it does assignment anyway
17:00 < kissiel> zyga: there's more than 1 place where json.loads happne
17:00 < kissiel> *happen
17:00 <@zyga> kissiel: can you be more specific?
17:00 <@zyga> this field is _assigned_ in one place
17:00 <@zyga> kissiel: json decode can and should just decode a string
17:01 <@zyga> kissiel: there is (and should be) only one place that assigns session_state.metadata.timestsamp
17:01 <@zyga> *stamp
17:01 < kissiel> mkay
17:01 < kissiel> I think I know what you mean
17:01 <@zyga> kissiel: fantastic, thanks
17:01 < kissiel> this will make use of the default json decoder
17:01 <@zyga> kissiel: well, just decode as before, but re-interpret that one field

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

There's one issue with resume unit tests, and we would need a test for resuming version 7 and a test to try the next (not yet available) 8 (test_resume_dispatch_v8):

======================================================================
ERROR: test_resume_dispatch_v7 (plainbox.impl.session.test_resume.SessionResumeHelperTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/sylvain/checkbox-repo/suspend-v7/plainbox/plainbox/impl/session/resume.py", line 1154, in _validate
    value = obj[key]
KeyError: 'session'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/sylvain/checkbox-repo/suspend-v7/plainbox/plainbox/impl/session/test_resume.py", line 248, in test_resume_dispatch_v7
    SessionResumeHelper([], None, None).resume(data)
  File "/home/sylvain/checkbox-repo/suspend-v7/plainbox/plainbox/impl/session/resume.py", line 301, in resume
    return self._resume_json(json_repr, early_cb)
  File "/home/sylvain/checkbox-repo/suspend-v7/plainbox/plainbox/impl/session/resume.py", line 339, in _resume_json
    return helper.resume_json(json_repr, early_cb)
  File "/home/sylvain/checkbox-repo/suspend-v7/plainbox/plainbox/impl/session/resume.py", line 654, in resume_json
    session_repr = _validate(json_repr, key='session', value_type=dict)
  File "/home/sylvain/checkbox-repo/suspend-v7/plainbox/plainbox/impl/session/resume.py", line 1159, in _validate
    raise CorruptedSessionError(error_msg)
plainbox.impl.session.resume.CorruptedSessionError: Missing value for key 'session'

review: Needs Fixing

Unmerged revisions

4119. By Maciej Kisielewski

plainbox:session:resume: bump date in header

Signed-off-by: Maciej Kisielewski <email address hidden>

4118. By Maciej Kisielewski

plainbox:session:resume: fix PEP-8 issues

Signed-off-by: Maciej Kisielewski <email address hidden>

4117. By Maciej Kisielewski

plainbox:session: add 7th suspend format

This one brings session timestamp and info about the selected timestamps to the
session storage.

Signed-off-by: Maciej Kisielewski <email address hidden>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'plainbox/plainbox/impl/session/resume.py'
2--- plainbox/plainbox/impl/session/resume.py 2015-09-09 08:28:34 +0000
3+++ plainbox/plainbox/impl/session/resume.py 2015-12-01 14:50:52 +0000
4@@ -1,6 +1,6 @@
5 # This file is part of Checkbox.
6 #
7-# Copyright 2012, 2013, 2014 Canonical Ltd.
8+# Copyright 2012-2015 Canonical Ltd.
9 # Written by:
10 # Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
11 #
12@@ -200,6 +200,8 @@
13 return SessionPeekHelper5().peek_json(json_repr)
14 elif version == 6:
15 return SessionPeekHelper6().peek_json(json_repr)
16+ elif version == 7:
17+ return SessionPeekHelper7().peek_json(json_repr)
18 else:
19 raise IncompatibleSessionError(
20 _("Unsupported version {}").format(version))
21@@ -328,6 +330,9 @@
22 elif version == 6:
23 helper = SessionResumeHelper6(
24 self.job_list, self.flags, self.location)
25+ elif version == 7:
26+ helper = SessionResumeHelper7(
27+ self.job_list, self.flags, self.location)
28 else:
29 raise IncompatibleSessionError(
30 _("Unsupported version {}").format(version))
31@@ -550,6 +555,19 @@
32 """
33
34
35+class SessionPeekHelper7(SessionPeekHelper6):
36+ """
37+ Helper class for implementing session peek feature
38+
39+ This class works with data constructed by
40+ :class:`~plainbox.impl.session.suspend.SessionSuspendHelper7` which has
41+ been pre-processed by :class:`SessionPeekHelper` (to strip the initial
42+ envelope).
43+
44+ The only goal of this class is to reconstruct session state meta-data.
45+ """
46+
47+
48 class SessionResumeHelper1(MetaDataHelper1MixIn):
49
50 """
51@@ -860,13 +878,13 @@
52 # - All of the jobs that we need to run (aka, the desired jobs
53 # list). This is pretty obvious and it is exactly what must
54 # be preserved or trim_job_list() will complain
55- set([job.id for job in session.run_list])
56+ set([job.id for job in session.run_list]) |
57 # - All of the jobs that have representation (aka checksum).
58 # We want those jobs because they have results (or they would not
59 # end up in the list as of format v4). If they have results we
60 # just have to keep them. Perhaps the session had a different
61 # selection earlier, who knows.
62- | set(jobs_repr)
63+ set(jobs_repr)
64 )
65 try:
66 # NOTE: this should never raise ValueError (which signals that we
67@@ -904,16 +922,16 @@
68 if 'io_log_filename' in result_repr:
69 io_log_filename = cls._load_io_log_filename(
70 result_repr, flags, location)
71- if (flags & cls.FLAG_FILE_REFERENCE_CHECKS_F
72- and not os.path.isfile(io_log_filename)
73- and flags & cls.FLAG_REWRITE_LOG_PATHNAMES_F):
74+ if (flags & cls.FLAG_FILE_REFERENCE_CHECKS_F and
75+ not os.path.isfile(io_log_filename) and
76+ flags & cls.FLAG_REWRITE_LOG_PATHNAMES_F):
77 io_log_filename2 = cls._rewrite_pathname(io_log_filename,
78 location)
79 logger.warning(_("Rewrote file name from %r to %r"),
80 io_log_filename, io_log_filename2)
81 io_log_filename = io_log_filename2
82- if (flags & cls.FLAG_FILE_REFERENCE_CHECKS_F
83- and not os.path.isfile(io_log_filename)):
84+ if (flags & cls.FLAG_FILE_REFERENCE_CHECKS_F and
85+ not os.path.isfile(io_log_filename)):
86 raise BrokenReferenceToExternalFile(
87 _("cannot access file: {!r}").format(io_log_filename))
88 return DiskJobResult({
89@@ -1062,6 +1080,7 @@
90 raise ValueError("Location must be a directory name")
91 return os.path.join(location, io_log_filename)
92
93+
94 class SessionResumeHelper6(SessionResumeHelper5):
95 """
96 Helper class for implementing session resume feature
97@@ -1119,6 +1138,12 @@
98 return session
99
100
101+class SessionResumeHelper7(SessionResumeHelper6):
102+ """
103+ Helper class for implementing session resume feature
104+ """
105+
106+
107 def _validate(obj, **flags):
108 """Multi-purpose extraction and validation function."""
109 # Fetch data from the container OR use json_repr directly
110
111=== modified file 'plainbox/plainbox/impl/session/state.py'
112--- plainbox/plainbox/impl/session/state.py 2015-09-03 11:00:21 +0000
113+++ plainbox/plainbox/impl/session/state.py 2015-12-01 14:50:52 +0000
114@@ -22,6 +22,7 @@
115 ============================================================
116 """
117 import collections
118+import datetime
119 import logging
120 import re
121
122@@ -70,7 +71,7 @@
123 FLAG_BOOTSTRAPPING = "bootstrapping"
124
125 def __init__(self, title=None, flags=None, running_job_name=None,
126- app_blob=None, app_id=None):
127+ app_blob=None, app_id=None, timestamp=None):
128 """Initialize a new session state meta-data object."""
129 if flags is None:
130 flags = []
131@@ -79,6 +80,7 @@
132 self._running_job_name = running_job_name
133 self._app_blob = app_blob
134 self._app_id = app_id
135+ self._timestamp = timestamp
136
137 def __repr__(self):
138 """Get the representation of the session state meta-data."""
139@@ -185,6 +187,16 @@
140 raise TypeError(_("app_id must be either None or str"))
141 self._app_id = value
142
143+ @property
144+ def timestamp(self):
145+ """
146+ POSIX time when the session was created.
147+
148+ Note that this a simple number (POSIX timestamp) to store this value,
149+ as it's easier to (de)serialize, and most of the time it will be used
150+ for sorting.
151+ """
152+ return self._timestamp
153
154 class SessionDeviceContext:
155
156@@ -365,6 +377,7 @@
157 added to the system.
158 """
159 self._test_plan_list = test_plan_list
160+ self._state.selected_test_plans = [unit.id for unit in test_plan_list]
161 self._invalidate_override_map()
162 self._bulk_override_update()
163 if test_plan_list:
164@@ -790,7 +803,9 @@
165 self._mandatory_job_list = []
166 self._run_list = []
167 self._resource_map = {}
168- self._metadata = SessionMetaData()
169+ self._selected_test_plans = []
170+ self._metadata = SessionMetaData(
171+ timestamp=datetime.datetime.timestamp(datetime.datetime.utcnow()))
172 super(SessionState, self).__init__()
173
174 def trim_job_list(self, qualifier):
175@@ -1199,6 +1214,16 @@
176 """Map from resource id to a list of resource records."""
177 return self._resource_map
178
179+ @property
180+ def selected_test_plans(self):
181+ """List of test plan ids that have been selected for execution."""
182+ return self._selected_test_plans
183+
184+ @selected_test_plans.setter
185+ def selected_test_plans(self, test_plans):
186+ """set the list of selected test plans."""
187+ self._selected_test_plans = test_plans
188+
189 def get_outcome_stats(self):
190 """
191 Process the JobState map to get stats about the job outcomes.
192
193=== modified file 'plainbox/plainbox/impl/session/suspend.py'
194--- plainbox/plainbox/impl/session/suspend.py 2015-08-20 11:17:46 +0000
195+++ plainbox/plainbox/impl/session/suspend.py 2015-12-01 14:50:52 +0000
196@@ -83,6 +83,8 @@
197 5) Same as '4' but DiskJobResult is stored with a relative pathname to the log
198 file if session_dir is provided.
199 6) Same as '5' plus store the list of mandatory jobs.
200+7) Same as '6' plus store session's timestamp and the list of test plan ids
201+ that were selected for execution.
202 """
203
204 import base64
205@@ -647,5 +649,127 @@
206 "metadata": self._repr_SessionMetaData(obj.metadata, session_dir),
207 }
208
209+class SessionSuspendHelper7(SessionSuspendHelper6):
210+ """
211+ Helper class for computing binary representation of a session.
212+
213+ The helper only creates a bytes object to save. Actual saving should
214+ be performed using some other means, preferably using
215+ :class:`~plainbox.impl.session.storage.SessionStorage`.
216+
217+ This class creates version '7' snapshots.
218+ """
219+
220+ VERSION = 7
221+
222+ def _repr_SessionState(self, obj, session_dir):
223+ """
224+ Compute the representation of :class:`SessionState`.
225+
226+ :returns:
227+ JSON-friendly representation
228+ :rtype:
229+ dict
230+
231+ The result is a dictionary with the following items:
232+
233+ ``jobs``:
234+ Dictionary mapping job id to job checksum.
235+ The checksum is computed with
236+ :attr:`~plainbox.impl.job.JobDefinition.checksum`.
237+ Two kinds of jobs are mentioned here:
238+ - jobs that ever ran and have a result
239+ - jobs that may run (are on the run list now)
240+ The idea is to capture the "state" of the jobs that are
241+ "important" to this session, that should be checked for
242+ modifications when the session resumes later.
243+
244+ ``results``
245+ Dictionary mapping job id to a list of results.
246+ Each result is represented by data computed by
247+ :meth:`_repr_JobResult()`. Only jobs that actually have
248+ a result are mentioned here. The automatically generated
249+ "None" result that is always present for every job is skipped.
250+
251+ ``desired_job_list``:
252+ List of (ids) of jobs that are desired (to be executed)
253+
254+ ``mandatory_job_list``:
255+ List of (ids) of jobs that must be executed
256+
257+ ``selected_test_plans``:
258+ List of (ids) of test plans that were selected for execution
259+
260+ ``metadata``:
261+ The representation of meta-data associated with the session
262+ state object.
263+ """
264+ id_run_list = frozenset([job.id for job in obj.run_list])
265+ return {
266+ "jobs": {
267+ state.job.id: state.job.checksum
268+ for state in obj.job_state_map.values()
269+ if not state.result.is_hollow or state.job.id in id_run_list
270+ },
271+ "results": {
272+ state.job.id: [self._repr_JobResult(result, session_dir)
273+ for result in state.result_history]
274+ for state in obj.job_state_map.values()
275+ if len(state.result_history) > 0
276+ },
277+ "desired_job_list": [
278+ job.id for job in obj.desired_job_list
279+ ],
280+ "mandatory_job_list": [
281+ job.id for job in obj.mandatory_job_list
282+ ],
283+ "selected_test_plans": obj.selected_test_plans,
284+ "metadata": self._repr_SessionMetaData(obj.metadata, session_dir),
285+ }
286+
287+ def _repr_SessionMetaData(self, obj, session_dir):
288+ """
289+ Compute the representation of :class:`SessionMetaData`.
290+
291+ :returns:
292+ JSON-friendly representation.
293+ :rtype:
294+ dict
295+
296+ The result is a dictionary with the following items:
297+
298+ ``title``:
299+ Title of the session. Arbitrary text provided by the
300+ application.
301+
302+ ``flags``:
303+ List of strings that enumerate the flags the session is in.
304+ There are some well-known flags but this list can have any
305+ items it it.
306+
307+ ``running_job_name``:
308+ Id of the job that was about to be executed before
309+ snapshotting took place. Can be None.
310+
311+ ``app_blob``:
312+ Arbitrary application specific binary blob encoded with base64.
313+ This field may be null.
314+
315+ ``app_id``:
316+ A string identifying the application that stored app_blob.
317+ Thirs field may be null.
318+
319+ ``timestamp``:
320+ A POSIX timestamp of the time when the session was created.
321+ """
322+ # reuse previous version
323+ data = super(SessionSuspendHelper7, self)._repr_SessionMetaData(
324+ obj, session_dir)
325+ # and add the timestamp
326+ data['timestamp'] = obj.timestamp
327+ return data
328+
329+
330+
331 # Alias for the most recent version
332-SessionSuspendHelper = SessionSuspendHelper6
333+SessionSuspendHelper = SessionSuspendHelper7
334
335=== modified file 'plainbox/plainbox/impl/session/test_suspend.py'
336--- plainbox/plainbox/impl/session/test_suspend.py 2015-08-28 10:02:09 +0000
337+++ plainbox/plainbox/impl/session/test_suspend.py 2015-12-01 14:50:52 +0000
338@@ -41,6 +41,7 @@
339 from plainbox.impl.session.suspend import SessionSuspendHelper4
340 from plainbox.impl.session.suspend import SessionSuspendHelper5
341 from plainbox.impl.session.suspend import SessionSuspendHelper6
342+from plainbox.impl.session.suspend import SessionSuspendHelper7
343 from plainbox.impl.testing_utils import make_job
344 from plainbox.vendor import mock
345
346@@ -989,6 +990,151 @@
347 })
348
349
350+class SessionSuspendHelper7Tests(SessionSuspendHelper6Tests):
351+ """Tests for various methods of SessionSuspendHelper7."""
352+ def setUp(self):
353+ self.helper = SessionSuspendHelper7()
354+ self.session_dir = None
355+
356+ def test_json_repr_current_version(self):
357+ """
358+ verify what the version field is
359+ """
360+ data = self.helper._json_repr(SessionState([]), self.session_dir)
361+ self.assertEqual(data['version'], 7)
362+
363+ @mock.patch('datetime.datetime')
364+ def test_suspend(self, mock_dt):
365+ mock_dt.timestamp.return_value = 5
366+ data = self.helper.suspend(SessionState([]), self.session_dir)
367+ self.assertIsInstance(data, bytes)
368+ self.assertEqual(gzip.decompress(data), (
369+ b'{"session":{"desired_job_list":[],"jobs":{},'
370+ b'"mandatory_job_list":[],"metadata":'
371+ b'{"app_blob":null,"app_id":null,"flags":[],'
372+ b'"running_job_name":null,"timestamp":5,"title":null},'
373+ b'"results":{},"selected_test_plans":[]},"version":7}'))
374+
375+ @mock.patch('datetime.datetime')
376+ def test_repr_SessionState_empty_session(self, mock_dt):
377+ """
378+ verify that representation of empty SessionState is okay
379+ """
380+ mock_dt.timestamp.return_value = 20
381+ data = self.helper._repr_SessionState(
382+ SessionState([]), self.session_dir)
383+ self.assertEqual(data, {
384+ 'jobs': {},
385+ 'results': {},
386+ 'desired_job_list': [],
387+ 'mandatory_job_list': [],
388+ 'selected_test_plans': [],
389+ 'metadata': {
390+ 'title': None,
391+ 'flags': [],
392+ 'running_job_name': None,
393+ 'app_blob': None,
394+ 'app_id': None,
395+ 'timestamp': 20
396+ },
397+ })
398+
399+ def test_repr_SessionMetaData_empty_metadata(self):
400+ """
401+ verify that representation of empty SessionMetaData is okay
402+ """
403+ # all defaults with empty values
404+ data = self.helper._repr_SessionMetaData(
405+ SessionMetaData(), self.session_dir)
406+ self.assertEqual(data, {
407+ 'title': None,
408+ 'flags': [],
409+ 'running_job_name': None,
410+ 'app_blob': None,
411+ 'app_id': None,
412+ 'timestamp': None,
413+ })
414+
415+ def test_repr_SessionMetaData_typical_metadata(self):
416+ """
417+ verify that representation of typical SessionMetaData is okay
418+ """
419+ # no surprises here, just the same data copied over
420+ data = self.helper._repr_SessionMetaData(SessionMetaData(
421+ title='USB Testing session',
422+ flags=['incomplete'],
423+ running_job_name='usb/detect',
424+ app_blob=b'blob',
425+ app_id='com.canonical.certification.plainbox',
426+ timestamp=100.0,
427+ ), self.session_dir)
428+ self.assertEqual(data, {
429+ 'title': 'USB Testing session',
430+ 'flags': ['incomplete'],
431+ 'running_job_name': 'usb/detect',
432+ 'app_blob': 'YmxvYg==',
433+ 'app_id': 'com.canonical.certification.plainbox',
434+ 'timestamp': 100.0
435+ })
436+
437+ @mock.patch('datetime.datetime')
438+ def test_repr_SessionState_typical_session(self, mock_dt):
439+ """
440+ verify the representation of a SessionState with some unused jobs
441+
442+ Unused jobs should just have no representation. Their checksum
443+ should not be mentioned. Their results (empty results) should be
444+ ignored.
445+ """
446+ mock_dt.timestamp.return_value = 40
447+ used_job = JobDefinition({
448+ "plugin": "shell",
449+ "id": "used",
450+ "command": "echo 'hello world'",
451+ })
452+ unused_job = JobDefinition({
453+ "plugin": "shell",
454+ "id": "unused",
455+ "command": "echo 'hello world'",
456+ })
457+ used_result = MemoryJobResult({
458+ "io_log": [
459+ (0.0, "stdout", b'hello world\n'),
460+ ],
461+ 'outcome': IJobResult.OUTCOME_PASS
462+ })
463+ session_state = SessionState([used_job, unused_job])
464+ session_state.update_desired_job_list([used_job])
465+ session_state.update_job_result(used_job, used_result)
466+ data = self.helper._repr_SessionState(session_state, self.session_dir)
467+ self.assertEqual(data, {
468+ 'jobs': {
469+ 'used': ('8c393c19fdfde1b6afc5b79d0a1617ecf7531cd832a16450dc'
470+ '2f3f50d329d373')
471+ },
472+ 'results': {
473+ 'used': [{
474+ 'comments': None,
475+ 'execution_duration': None,
476+ 'io_log': [[0.0, 'stdout', 'aGVsbG8gd29ybGQK']],
477+ 'outcome': 'pass',
478+ 'return_code': None
479+ }]
480+ },
481+ 'desired_job_list': ['used'],
482+ 'mandatory_job_list': [],
483+ 'selected_test_plans': [],
484+ 'metadata': {
485+ 'title': None,
486+ 'flags': [],
487+ 'running_job_name': None,
488+ 'app_blob': None,
489+ 'app_id': None,
490+ 'timestamp': 40,
491+ },
492+ })
493+
494+
495 class RegressionTests(TestCase):
496
497 def test_1388055(self):

Subscribers

People subscribed via source and target branches