Merge lp:~zyga/checkbox/fix-1378300 into lp:checkbox

Proposed by Zygmunt Krynicki
Status: Merged
Approved by: Daniel Manrique
Approved revision: 3352
Merged at revision: 3344
Proposed branch: lp:~zyga/checkbox/fix-1378300
Merge into: lp:checkbox
Diff against target: 467 lines (+264/-14)
8 files modified
plainbox/plainbox/abc.py (+14/-0)
plainbox/plainbox/impl/result.py (+14/-0)
plainbox/plainbox/impl/session/jobs.py (+2/-3)
plainbox/plainbox/impl/session/resume.py (+46/-4)
plainbox/plainbox/impl/session/suspend.py (+69/-1)
plainbox/plainbox/impl/session/test_resume.py (+23/-6)
plainbox/plainbox/impl/session/test_suspend.py (+92/-0)
plainbox/plainbox/impl/test_result.py (+4/-0)
To merge this branch: bzr merge lp:~zyga/checkbox/fix-1378300
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Review via email: mp+237655@code.launchpad.net

Description of the change

f29a9ee plainbox:result: add JobResultBase.is_hollow
6df3da5 plainbox:abc: add IJobResult.is_hollow
27a0bfe plainbox:session:jobs: ensure that initial memory results are hollow
b3d3d48 plainbox:session:resume: always retain desired jobs
2d44c53 plainbox:session:resume: add 4th resume helper
f205eb1 plainbox:session:resume: enable 4th resume helper
bef4ea9 plainbox:session:resume: add 4th peek helper
f6aed57 plainbox:session:resume: enable 4th peek helper
7ab91e8 plainbox:session:suspend: add 4th suspend format
5e753c6 plainbox:session:suspend: use the 4th format by default

To post a comment you must log in.
Revision history for this message
Daniel Manrique (roadmr) wrote :

OK, I tested it (see repro case in the last comment on the bug, and the original report is good for this too), and the code makes sense.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'plainbox/plainbox/abc.py'
2--- plainbox/plainbox/abc.py 2014-09-25 15:31:26 +0000
3+++ plainbox/plainbox/abc.py 2014-10-08 18:01:17 +0000
4@@ -264,6 +264,20 @@
5 bytes object that was obtained from that stream.
6 """
7
8+ @abstractproperty
9+ def is_hollow(self):
10+ """
11+ flag that indicates if the result is hollow
12+
13+ Hollow results may have been created but hold no data at all.
14+ Hollow results are also tentatively deprecated, once we have some
15+ time to re-factor SessionState and specifically the job_state_map
16+ code we will remove the need to have hollow results.
17+
18+ Hollow results are not saved, beginning with
19+ :class:`plainbox.impl.session.suspend.SessionSuspendHelper4`.
20+ """
21+
22
23 class IJobQualifier(metaclass=ABCMeta):
24 """
25
26=== modified file 'plainbox/plainbox/impl/result.py'
27--- plainbox/plainbox/impl/result.py 2014-10-06 09:50:18 +0000
28+++ plainbox/plainbox/impl/result.py 2014-10-08 18:01:17 +0000
29@@ -185,6 +185,20 @@
30 def io_log(self):
31 return tuple(self.get_io_log())
32
33+ @property
34+ def is_hollow(self):
35+ """
36+ flag that indicates if the result is hollow
37+
38+ Hollow results may have been created but hold no data at all.
39+ Hollow results are also tentatively deprecated, once we have some
40+ time to re-factor SessionState and specifically the job_state_map
41+ code we will remove the need to have hollow results.
42+
43+ Hollow results are not saved, beginning with
44+ :class:`plainbox.impl.session.suspend.SessionSuspendHelper4`.
45+ """
46+ return not bool(self._data)
47
48 class MemoryJobResult(_JobResultBase):
49 """
50
51=== modified file 'plainbox/plainbox/impl/session/jobs.py'
52--- plainbox/plainbox/impl/session/jobs.py 2014-09-13 11:37:17 +0000
53+++ plainbox/plainbox/impl/session/jobs.py 2014-10-08 18:01:17 +0000
54@@ -201,9 +201,8 @@
55 """
56 self._job = job
57 self._readiness_inhibitor_list = [UndesiredJobReadinessInhibitor]
58- self._result = MemoryJobResult({
59- 'outcome': IJobResult.OUTCOME_NONE
60- })
61+ self._result = MemoryJobResult({})
62+ assert self._result.is_hollow
63
64 def __repr__(self):
65 fmt = ("<{} job:{!r} readiness_inhibitor_list:{!r} result:{!r}>")
66
67=== modified file 'plainbox/plainbox/impl/session/resume.py'
68--- plainbox/plainbox/impl/session/resume.py 2014-09-12 13:57:59 +0000
69+++ plainbox/plainbox/impl/session/resume.py 2014-10-08 18:01:17 +0000
70@@ -160,6 +160,8 @@
71 return SessionPeekHelper2().peek_json(json_repr)
72 elif version == 3:
73 return SessionPeekHelper3().peek_json(json_repr)
74+ elif version == 4:
75+ return SessionPeekHelper4().peek_json(json_repr)
76 else:
77 raise IncompatibleSessionError(
78 _("Unsupported version {}").format(version))
79@@ -236,6 +238,9 @@
80 elif version == 3:
81 return SessionResumeHelper3(
82 self.job_list).resume_json(json_repr, early_cb)
83+ elif version == 4:
84+ return SessionResumeHelper4(
85+ self.job_list).resume_json(json_repr, early_cb)
86 else:
87 raise IncompatibleSessionError(
88 _("Unsupported version {}").format(version))
89@@ -247,11 +252,13 @@
90 after doing a session resume.
91 """
92
93- def __init__(self, jobs_repr):
94+ def __init__(self, jobs_repr, desired_job_list_repr):
95 super().__init__(Origin.get_caller_origin())
96 # Set of ids of jobs to retain (computed as keys of the
97 # dictionary taken from the session resume representation)
98- self._retain_id_set = frozenset(jobs_repr)
99+ self._retain_id_set = (
100+ frozenset(jobs_repr)
101+ | frozenset(desired_job_list_repr))
102
103 def get_simple_match(self, job):
104 return job.id not in self._retain_id_set
105@@ -393,6 +400,19 @@
106 """
107
108
109+class SessionPeekHelper4(SessionPeekHelper3):
110+ """
111+ Helper class for implementing session peek feature
112+
113+ This class works with data constructed by
114+ :class:`~plainbox.impl.session.suspend.SessionSuspendHelper1` which has
115+ been pre-processed by :class:`SessionPeekHelper` (to strip the initial
116+ envelope).
117+
118+ The only goal of this class is to reconstruct session state meta-data.
119+ """
120+
121+
122 class SessionResumeHelper1(MetaDataHelper1MixIn):
123 """
124 Helper class for implementing session resume feature
125@@ -607,10 +627,13 @@
126 go wrong must have gone wrong before.
127 """
128
129- # Representation of all of the job definitions
130+ # Representation of all of the important job definitions
131 jobs_repr = _validate(session_repr, key='jobs', value_type=dict)
132+ # Representation of all the desired job definitions
133+ desired_job_list_repr = _validate(
134+ session_repr, key='desired_job_list', value_type=list)
135 # Qualifier ready to select jobs to remove
136- qualifier = ResumeDiscardQualifier(jobs_repr)
137+ qualifier = ResumeDiscardQualifier(jobs_repr, desired_job_list_repr)
138 # NOTE: this should never raise ValueError (which signals that we
139 # tried to remove a job which is in the run list) because it should
140 # only remove jobs that were not in the representation and any job in
141@@ -726,6 +749,25 @@
142 """
143
144
145+class SessionResumeHelper4(SessionResumeHelper3):
146+ """
147+ Helper class for implementing session resume feature
148+
149+ This class works with data constructed by
150+ :class:`~plainbox.impl.session.suspend.SessionSuspendHelper4` which has
151+ been pre-processed by :class:`SessionResumeHelper` (to strip the initial
152+ envelope).
153+
154+ Due to the constraints of what can be represented in a suspended session,
155+ this class cannot work in isolation. It must operate with a list of know
156+ jobs.
157+
158+ Since (most of the) jobs are being provided externally (as they represent
159+ the non-serialized parts of checkbox or other job providers) several
160+ failure modes are possible. Those are documented in :meth:`resume()`
161+ """
162+
163+
164 def _validate(obj, **flags):
165 """
166 Multi-purpose extraction and validation function.
167
168=== modified file 'plainbox/plainbox/impl/session/suspend.py'
169--- plainbox/plainbox/impl/session/suspend.py 2014-03-17 11:31:12 +0000
170+++ plainbox/plainbox/impl/session/suspend.py 2014-10-08 18:01:17 +0000
171@@ -77,6 +77,8 @@
172 :attr:`plainbox.impl.session.state.SessionMetaData.app_blob`
173 3) Same as '2' but suspends
174 :attr:`plainbox.impl.session.state.SessionMetaData.app_id`
175+4) Same as '3' but hollow results are not saved and jobs that only
176+ have hollow results are not mentioned in the job -> checksum map.
177 """
178
179 import gzip
180@@ -435,5 +437,71 @@
181 return data
182
183
184+class SessionSuspendHelper4(SessionSuspendHelper3):
185+ """
186+ Helper class for computing binary representation of a session.
187+
188+ The helper only creates a bytes object to save. Actual saving should
189+ be performed using some other means, preferably using
190+ :class:`~plainbox.impl.session.storage.SessionStorage`.
191+
192+ This class creates version '4' snapshots.
193+ """
194+
195+ VERSION = 4
196+
197+ def _repr_SessionState(self, obj):
198+ """
199+ Compute the representation of :class:`SessionState`
200+
201+ :returns:
202+ JSON-friendly representation
203+ :rtype:
204+ dict
205+
206+ The result is a dictionary with the following items:
207+
208+ ``jobs``:
209+ Dictionary mapping job id to job checksum.
210+ The checksum is computed with
211+ :attr:`~plainbox.impl.job.JobDefinition.checksum`.
212+ Only jobs that actually have a result are mentioned here.
213+ The automatically generated "None" result that is always
214+ present for every job is skipped.
215+
216+ ``results``
217+ Dictionary mapping job id to a list of results.
218+ Each result is represented by data computed by
219+ :meth:`_repr_JobResult()`. Only jobs that actually have
220+ a result are mentioned here. The automatically generated
221+ "None" result that is always present for every job is skipped.
222+
223+ ``desired_job_list``:
224+ List of (ids) of jobs that are desired (to be executed)
225+
226+ ``metadata``:
227+ The representation of meta-data associated with the session
228+ state object.
229+ """
230+ return {
231+ "jobs": {
232+ state.job.id: state.job.checksum
233+ for state in obj.job_state_map.values()
234+ if not state.result.is_hollow
235+ },
236+ "results": {
237+ # Currently we store only one result but we may store
238+ # more than that in a later version.
239+ state.job.id: [self._repr_JobResult(state.result)]
240+ for state in obj.job_state_map.values()
241+ if not state.result.is_hollow
242+ },
243+ "desired_job_list": [
244+ job.id for job in obj.desired_job_list
245+ ],
246+ "metadata": self._repr_SessionMetaData(obj.metadata),
247+ }
248+
249+
250 # Alias for the most recent version
251-SessionSuspendHelper = SessionSuspendHelper3
252+SessionSuspendHelper = SessionSuspendHelper4
253
254=== modified file 'plainbox/plainbox/impl/session/test_resume.py'
255--- plainbox/plainbox/impl/session/test_resume.py 2014-07-30 09:20:03 +0000
256+++ plainbox/plainbox/impl/session/test_resume.py 2014-10-08 18:01:17 +0000
257@@ -61,10 +61,11 @@
258 def setUp(self):
259 # The initializer accepts the jobs representation dictionary but uses
260 # keys only. Here the values are dummy None objects
261- self.obj = ResumeDiscardQualifier({'foo': None, 'bar': None})
262+ self.obj = ResumeDiscardQualifier({'foo': None, 'bar': None}, ['froz'])
263
264 def test_init(self):
265- self.assertEqual(self.obj._retain_id_set, frozenset(['foo', 'bar']))
266+ self.assertEqual(
267+ self.obj._retain_id_set, frozenset(['foo', 'bar', 'froz']))
268
269 def test_get_simple_match(self):
270 # Direct hits return the IGNORE vote as those jobs are not to be
271@@ -76,6 +77,9 @@
272 self.assertEqual(
273 self.obj.get_vote(JobDefinition({'id': 'bar'})),
274 IJobQualifier.VOTE_IGNORE)
275+ self.assertEqual(
276+ self.obj.get_vote(JobDefinition({'id': 'froz'})),
277+ IJobQualifier.VOTE_IGNORE)
278 # Jobs that are in the retain set are NOT designated
279 self.assertEqual(
280 self.obj.designates(JobDefinition({'id': 'bar'})), False)
281@@ -140,12 +144,22 @@
282 SessionResumeHelper([]).resume(data)
283 mocked_helper3.resume_json.assertCalledOnce()
284
285- def test_resume_dispatch_v4(self):
286- data = gzip.compress(
287- b'{"version":4}')
288+ @mock.patch('plainbox.impl.session.resume.SessionResumeHelper4')
289+ def test_resume_dispatch_v4(self, mocked_helper4):
290+ data = gzip.compress(
291+ b'{"session":{"desired_job_list":[],"jobs":{},"metadata":'
292+ b'{"app_blob":null,"app_id":null,"flags":[],'
293+ b'"running_job_name":null,"title":null'
294+ b'},"results":{}},"version":4}')
295+ SessionResumeHelper([]).resume(data)
296+ mocked_helper4.resume_json.assertCalledOnce()
297+
298+ def test_resume_dispatch_v5(self):
299+ data = gzip.compress(
300+ b'{"version":5}')
301 with self.assertRaises(IncompatibleSessionError) as boom:
302 SessionResumeHelper([]).resume(data)
303- self.assertEqual(str(boom.exception), "Unsupported version 4")
304+ self.assertEqual(str(boom.exception), "Unsupported version 5")
305
306
307 class SessionResumeTests(TestCase):
308@@ -1710,6 +1724,9 @@
309 'jobs': {
310 job_a.id: job_a.checksum
311 },
312+ 'desired_job_list': [
313+ job_a.id
314+ ],
315 'results': {
316 job_a.id: [],
317 }
318
319=== modified file 'plainbox/plainbox/impl/session/test_suspend.py'
320--- plainbox/plainbox/impl/session/test_suspend.py 2014-07-30 09:06:50 +0000
321+++ plainbox/plainbox/impl/session/test_suspend.py 2014-10-08 18:01:17 +0000
322@@ -28,6 +28,7 @@
323 from unittest import TestCase
324 import gzip
325
326+from plainbox.abc import IJobResult
327 from plainbox.impl.job import JobDefinition
328 from plainbox.impl.result import DiskJobResult
329 from plainbox.impl.result import IOLogRecord
330@@ -37,6 +38,7 @@
331 from plainbox.impl.session.suspend import SessionSuspendHelper1
332 from plainbox.impl.session.suspend import SessionSuspendHelper2
333 from plainbox.impl.session.suspend import SessionSuspendHelper3
334+from plainbox.impl.session.suspend import SessionSuspendHelper4
335 from plainbox.vendor import mock
336
337
338@@ -686,3 +688,93 @@
339 b'{"app_blob":null,"app_id":null,"flags":[],'
340 b'"running_job_name":null,"title":null},"results":{}},'
341 b'"version":3}'))
342+
343+
344+class SessionSuspendHelper4Tests(SessionSuspendHelper3Tests):
345+ """
346+ Tests for various methods of SessionSuspendHelper4
347+ """
348+
349+ def setUp(self):
350+ self.helper = SessionSuspendHelper4()
351+
352+ def test_json_repr_current_version(self):
353+ """
354+ verify what the version field is
355+ """
356+ data = self.helper._json_repr(SessionState([]))
357+ self.assertEqual(data['version'], 4)
358+
359+ def test_repr_SessionState_typical_session(self):
360+ """
361+ verify the representation of a SessionState with some unused jobs
362+
363+ Unused jobs should just have no representation. Their checksum
364+ should not be mentioned. Their results (empty results) should be
365+ ignored.
366+ """
367+ used_job = JobDefinition({
368+ "plugin": "shell",
369+ "id": "used",
370+ "command": "echo 'hello world'",
371+ })
372+ unused_job = JobDefinition({
373+ "plugin": "shell",
374+ "id": "unused",
375+ "command": "echo 'hello world'",
376+ })
377+ used_result = MemoryJobResult({
378+ "io_log": [
379+ (0.0, "stdout", b'hello world\n'),
380+ ],
381+ 'outcome': IJobResult.OUTCOME_PASS
382+ })
383+ session_state = SessionState([used_job, unused_job])
384+ session_state.update_desired_job_list([used_job])
385+ session_state.update_job_result(used_job, used_result)
386+ data = self.helper._repr_SessionState(session_state)
387+ self.assertEqual(data, {
388+ 'jobs': {
389+ 'used': ('8c393c19fdfde1b6afc5b79d0a1617ecf7531cd832a16450dc'
390+ '2f3f50d329d373')
391+ },
392+ 'results': {
393+ 'used': [{
394+ 'comments': None,
395+ 'execution_duration': None,
396+ 'io_log': [[0.0, 'stdout', 'aGVsbG8gd29ybGQK']],
397+ 'outcome': 'pass',
398+ 'return_code': None
399+ }]
400+ },
401+ 'desired_job_list': ['used'],
402+ 'metadata': {
403+ 'title': None,
404+ 'flags': [],
405+ 'running_job_name': None,
406+ 'app_blob': None,
407+ 'app_id': None,
408+ },
409+ })
410+
411+ def test_suspend(self):
412+ """
413+ verify that the suspend() method returns gzipped JSON representation
414+ """
415+ data = self.helper.suspend(SessionState([]))
416+ # XXX: we cannot really test what the compressed data looks like
417+ # because apparently python3.2 gzip output is non-deterministic.
418+ # It seems to be an instance of the gzip bug that was fixed a few
419+ # years ago.
420+ #
421+ # I've filed a bug on python3.2 in Ubuntu and Python upstream project
422+ # https://bugs.launchpad.net/ubuntu/+source/python3.2/+bug/871083
423+ #
424+ # In the meantime we can only test that we got bytes out
425+ self.assertIsInstance(data, bytes)
426+ # And that we can gzip uncompress them and get what we expected
427+ self.assertEqual(gzip.decompress(data), (
428+ b'{"session":{"desired_job_list":[],"jobs":{},"metadata":'
429+ b'{"app_blob":null,"app_id":null,"flags":[],'
430+ b'"running_job_name":null,"title":null},"results":{}},'
431+ b'"version":4}'))
432
433=== modified file 'plainbox/plainbox/impl/test_result.py'
434--- plainbox/plainbox/impl/test_result.py 2014-02-17 16:25:52 +0000
435+++ plainbox/plainbox/impl/test_result.py 2014-10-08 18:01:17 +0000
436@@ -52,6 +52,7 @@
437 self.assertIsNone(result.comments)
438 self.assertEqual(result.io_log, ())
439 self.assertIsNone(result.return_code)
440+ self.assertTrue(result.is_hollow)
441
442 def test_everything(self):
443 result = DiskJobResult({
444@@ -73,6 +74,7 @@
445 self.assertEqual(result.comments, "it said blah")
446 self.assertEqual(result.io_log, ((0, 'stdout', b'blah\n'),))
447 self.assertEqual(result.return_code, 0)
448+ self.assertFalse(result.is_hollow)
449
450
451 class MemoryJobResultTests(TestCase):
452@@ -85,6 +87,7 @@
453 self.assertIsNone(result.comments)
454 self.assertEqual(result.io_log, ())
455 self.assertIsNone(result.return_code)
456+ self.assertTrue(result.is_hollow)
457
458 def test_everything(self):
459 result = MemoryJobResult({
460@@ -102,6 +105,7 @@
461 self.assertEqual(result.comments, "it said blah")
462 self.assertEqual(result.io_log, ((0, 'stdout', b'blah\n'),))
463 self.assertEqual(result.return_code, 0)
464+ self.assertFalse(result.is_hollow)
465
466
467 class IOLogRecordWriterTests(TestCase):

Subscribers

People subscribed via source and target branches