Merge lp:~jml/lazr.restfulclient/multiple-instance-safe into lp:lazr.restfulclient

Proposed by Jonathan Lange
Status: Merged
Approved by: j.c.sackett
Approved revision: 141
Merged at revision: 122
Proposed branch: lp:~jml/lazr.restfulclient/multiple-instance-safe
Merge into: lp:lazr.restfulclient
Diff against target: 319 lines (+260/-6)
3 files modified
buildout.cfg (+1/-0)
src/lazr/restfulclient/_browser.py (+101/-6)
src/lazr/restfulclient/tests/test_atomicfilecache.py (+158/-0)
To merge this branch: bzr merge lp:~jml/lazr.restfulclient/multiple-instance-safe
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Richard Harding code* Approve
Review via email: mp+98873@code.launchpad.net

Commit message

Creates AtomicFileCache as a parent class for MultipleRepresentationCache to handle concurrent use issues.

Description of the change

Hello,

I now work on a couple of projects that carry workarounds for bug 459418. It's not economical for my employer to be constantly maintaining these workarounds and to keep hitting this bug when writing new code that uses launchpadlib. As such, I offer this patch to make the file cache used by lazr.restfulclient safely shareable by multiple processes/threads.

It's an adaptation of the patch at http://code.google.com/p/httplib2/issues/detail?id=125.

It adds a new class, AtomicFileCache, and makes the MultipleRepresentationCache subclass that instead of the one that comes with httplib2.

Essentially, there are two components:

 1. Change look-before-you-leap to try/except. This prevents races where a thing exists (or doesn't exist) when we do the check, but then doesn't exist (or exists) when we do the operation.

 2. Do an atomic write in set, relying on the filesystem's atomic rename feature. This is not actually atomic on Windows, but will not be any buggier for them than the current code.

If you are interested in the subject of atomic writes, you might like to read <http://twistedmatrix.com/documents/current/api/twisted.python.filepath.FilePath.html#setContent>. The implementation of the method is much the same as set() here.

There are no tests. It's really hard to produce reliable tests for the race conditions that you find here without substantially changing the public API of the file cache. httplib2 doesn't have any tests for the interface of FileCache, otherwise I would have tried to re-use those. It wouldn't be too draconian to insist on adding some basic interface tests (e.g. does get() get what set() sets?) here, I think.

I look forward to hearing from you.

jml

To post a comment you must log in.
Revision history for this message
Richard Harding (rharding) wrote :

Thanks for this.

review: Approve (code*)
Revision history for this message
j.c.sackett (jcsackett) wrote :

Jml:

This looks really great. I'm going to be that not-too-draconian reviewer and ask for the basic tests you've suggested.

I absolutely concur that creating a test for the race condition is out of consideration--the test case would like be sufficiently complicated that failures might be from a change to the test, not to the code being tested.

Next, I note several imports being whacked--I assume those are just not in use in the code anymore, correct?

Lastly, do you have landing rights for this, or will you need someone to push it along for you?

review: Needs Information
Revision history for this message
Martin Pool (mbp) wrote :

I'm very grateful for you addressing this bug.

This is very likely to fail on Windows due to the file being unable to be overwritten if it's in use. I know people have at least tried to use lazr.restfulclient and lplib on Windows. I don't know if it's actually supported or working. If it does work a bit, you might want to try not to regress it.

Revision history for this message
Robert Collins (lifeless) wrote :

+1 for the LoC increase per
https://dev.launchpad.net/PolicyAndProcess/MaintenanceCosts - this is
a significant source of friction in using lazr.restful.

-Rob

131. By Jonathan Lange

Remove the destination file if the platform is windows (thanks mbp)

132. By Jonathan Lange

Initial tests for atomic file cache.

133. By Jonathan Lange

Interface tests for FileCache.

134. By Jonathan Lange

Extract out how we make the file cache.

135. By Jonathan Lange

Rename the test class.

136. By Jonathan Lange

Test things not being strings.

137. By Jonathan Lange

Tests for unicode behaviour.

138. By Jonathan Lange

Run the tests against httplib2.FileCache in order to prevent interface drift.

139. By Jonathan Lange

Copyright update.

140. By Jonathan Lange

Implementation-specific tests.

141. By Jonathan Lange

Docstrings.

Revision history for this message
Jonathan Lange (jml) wrote :

Hello all,

Thanks for the reviews, and for the free pass on increasing LoC.

I've added interface tests for FileCache and have set these up to run against both httplib2.FileCache and AtomicFileCache. The difference it makes to test run time is pretty small relative to current run time, and it will prevent interface drift.

Adding these tests revealed one difference between FileCache and AtomicFileCache: if FileCache fails to set(), say, because of a TypeError, then it will return '' on get() for that key; AtomicFileCache will return None, as if the key were never set. I think the difference in behaviour doesn't actually matter for our purposes, but I've left the tests in as documentation anyway. I also think that AtomicFileCache behaves correctly here and that FileCache itself is wrong.

I have also added docstrings, which I hope will help.

Per Martin's comment, I've updated set() to remove the file before renaming it, following the pattern laid out in twisted.python.filepath (which has had extensive usage and testing on Windows).

JC, yes, the removed imports are unused. If you don't believe me, well, I guess the automated test suite will catch me out ;)

I don't have sufficient permissions to land this myself, nor do I actually know how to land it. (Is it PQM-managed? Tarmac? etc.) I would be grateful if you would land it for me.

cheers,
jml

Revision history for this message
j.c.sackett (jcsackett) wrote :

> I've added interface tests for FileCache and have set these up to run against
> both httplib2.FileCache and AtomicFileCache. The difference it makes to test
> run time is pretty small relative to current run time, and it will prevent
> interface drift.
>
> Adding these tests revealed one difference between FileCache and
> AtomicFileCache: if FileCache fails to set(), say, because of a TypeError,
> then it will return '' on get() for that key; AtomicFileCache will return
> None, as if the key were never set. I think the difference in behaviour
> doesn't actually matter for our purposes, but I've left the tests in as
> documentation anyway. I also think that AtomicFileCache behaves correctly
> here and that FileCache itself is wrong.
>
> I have also added docstrings, which I hope will help.

Fantastic, thanks for those changes.

> JC, yes, the removed imports are unused. If you don't believe me, well, I
> guess the automated test suite will catch me out ;)

I believe you; I couldn't get at the code myself on Thursday for reasons best attributed to massive ISP fail, so fell back on the old method of asking. :-p

> I don't have sufficient permissions to land this myself, nor do I actually
> know how to land it. (Is it PQM-managed? Tarmac? etc.) I would be grateful if
> you would land it for me.

I will see what I can do; I think this is something we certainly want landed swiftly.
> cheers,
> jml

review: Approve
Revision history for this message
Jonathan Lange (jml) wrote :

On 26 March 2012 15:16, j.c.sackett <email address hidden> wrote:
...
>> I don't have sufficient permissions to land this myself, nor do I actually
>> know how to land it. (Is it PQM-managed? Tarmac? etc.)  I would be grateful if
>> you would land it for me.
>
> I will see what I can do; I think this is something we certainly want landed swiftly.

Thanks! A release would help a lot too.

jml

Revision history for this message
Jonathan Lange (jml) wrote :

How's it going getting this landed? (It's filling up a WIP slot on our kanban).

Revision history for this message
Martin Pool (mbp) wrote :

I don't want to stall this, but I do think that

    os.unlink(cache_full_path)

may still fail on Windows if the existing file is in use by some other client. Solutions could include:

 - just log it (perhaps silent by default) and continue
 - warn
 - retry for a bit (not great because it slows down the writer)
 - change to using win32 apis that don't lock against deletion

I don't know how well this works on Windows at the moment, but if people are using it this might be a regression.

Revision history for this message
Robert Collins (lifeless) wrote :

On Mon, Apr 2, 2012 at 5:24 PM, Martin Pool <email address hidden> wrote:
> I don't want to stall this, but I do think that
>
>    os.unlink(cache_full_path)
>
> may still fail on Windows if the existing file is in use by some other client.  Solutions could include:
>
>  - just log it (perhaps silent by default) and continue
>  - warn
>  - retry for a bit (not great because it slows down the writer)
>  - change to using win32 apis that don't lock against deletion
>
> I don't know how well this works on Windows at the moment, but if people are using it this might be a regression.

It will die horribly at the moment, this isn't going to be a
regression: we don't currently support concurrent use. This patch will
give us concurrent use on Linux. A future iteration can do concurrent
use on win32.

-Rob

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'buildout.cfg'
2--- buildout.cfg 2011-11-18 20:23:48 +0000
3+++ buildout.cfg 2012-03-25 14:24:17 +0000
4@@ -9,6 +9,7 @@
5 unzip = true
6 develop = .
7 include-site-packages = false
8+download-cache = download-cache
9
10 [test]
11 recipe = zc.recipe.testrunner
12
13=== modified file 'src/lazr/restfulclient/_browser.py'
14--- src/lazr/restfulclient/_browser.py 2011-06-28 05:15:02 +0000
15+++ src/lazr/restfulclient/_browser.py 2012-03-25 14:24:17 +0000
16@@ -1,4 +1,4 @@
17-# Copyright 2008 Canonical Ltd.
18+# Copyright 2008,2012 Canonical Ltd.
19
20 # This file is part of lazr.restfulclient.
21 #
22@@ -29,19 +29,21 @@
23 'RestfulHttp',
24 ]
25
26-
27 import atexit
28-import gzip
29+import errno
30+import os
31 import shutil
32+import sys
33 import tempfile
34 # Import sleep directly into the module so we can monkey-patch it
35 # during a test.
36 from time import sleep
37 from httplib2 import (
38- FailedToDecompressContent, FileCache, Http, urlnorm)
39+ Http,
40+ urlnorm,
41+ )
42 import simplejson
43 from cStringIO import StringIO
44-import zlib
45
46 from urllib import urlencode
47 from wadllib.application import Application
48@@ -49,6 +51,7 @@
49 from errors import error_for, HTTPError
50 from _json import DatetimeJSONEncoder
51
52+
53 # A drop-in replacement for httplib2's safename.
54 from httplib2 import _md5, re_url_scheme, re_slash
55 def safename(filename):
56@@ -136,7 +139,99 @@
57 return None
58
59
60-class MultipleRepresentationCache(FileCache):
61+class AtomicFileCache(object):
62+ """A FileCache that can be shared by multiple processes.
63+
64+ Based on a patch found at
65+ <http://code.google.com/p/httplib2/issues/detail?id=125>.
66+ """
67+
68+ TEMPFILE_PREFIX = ".temp"
69+
70+ def __init__(self, cache, safe=safename):
71+ """Construct an ``AtomicFileCache``.
72+
73+ :param cache: The directory to use as a cache.
74+ :param safe: A function that takes a key and returns a name that's
75+ safe to use as a filename. The key must never return a string
76+ that begins with ``TEMPFILE_PREFIX``. By default uses
77+ ``safename``.
78+ """
79+ self._cache_dir = os.path.normpath(cache)
80+ self._get_safe_name = safe
81+ try:
82+ os.makedirs(self._cache_dir)
83+ except OSError, e:
84+ if e.errno != errno.EEXIST:
85+ raise
86+
87+ def _get_key_path(self, key):
88+ """Return the path on disk where ``key`` is stored."""
89+ safe_key = self._get_safe_name(key)
90+ if safe_key.startswith(self.TEMPFILE_PREFIX):
91+ # If the cache key starts with the tempfile prefix, then it's
92+ # possible that it will clash with a temporary file that we
93+ # create.
94+ raise ValueError(
95+ "Cache key cannot start with '%s'" % self.TEMPFILE_PREFIX)
96+ return os.path.join(self._cache_dir, safe_key)
97+
98+ def get(self, key):
99+ """Get the value of ``key`` if set.
100+
101+ This behaves slightly differently to ``FileCache`` in that if
102+ ``set()`` fails to store a key, this ``get()`` will behave as if that
103+ key were never set whereas ``FileCache`` returns the empty string.
104+
105+ :param key: The key to retrieve. Must be either bytes or unicode
106+ text.
107+ :return: The value of ``key`` if set, None otherwise.
108+ """
109+ cache_full_path = self._get_key_path(key)
110+ try:
111+ f = open(cache_full_path, 'rb')
112+ try:
113+ return f.read()
114+ finally:
115+ f.close()
116+ except (IOError, OSError), e:
117+ if e.errno != errno.ENOENT:
118+ raise
119+
120+ def set(self, key, value):
121+ """Set ``key`` to ``value``.
122+
123+ :param key: The key to set. Must be either bytes or unicode text.
124+ :param value: The value to set ``key`` to. Must be bytes.
125+ """
126+ # Open a temporary file
127+ handle, path_name = tempfile.mkstemp(
128+ prefix=self.TEMPFILE_PREFIX, dir=self._cache_dir)
129+ f = os.fdopen(handle, 'wb')
130+ f.write(value)
131+ f.close()
132+ cache_full_path = self._get_key_path(key)
133+ # And rename atomically (on POSIX at least)
134+ if sys.platform == 'win32' and os.path.exists(cache_full_path):
135+ os.unlink(cache_full_path)
136+ os.rename(path_name, cache_full_path)
137+
138+ def delete(self, key):
139+ """Delete ``key`` from the cache.
140+
141+ If ``key`` has not already been set then has no effect.
142+
143+ :param key: The key to delete. Must be either bytes or unicode text.
144+ """
145+ cache_full_path = self._get_key_path(key)
146+ try:
147+ os.remove(cache_full_path)
148+ except OSError, e:
149+ if e.errno != errno.ENOENT:
150+ raise
151+
152+
153+class MultipleRepresentationCache(AtomicFileCache):
154 """A cache that can hold different representations of the same resource.
155
156 If a resource has two representations with two media types,
157
158=== added file 'src/lazr/restfulclient/tests/test_atomicfilecache.py'
159--- src/lazr/restfulclient/tests/test_atomicfilecache.py 1970-01-01 00:00:00 +0000
160+++ src/lazr/restfulclient/tests/test_atomicfilecache.py 2012-03-25 14:24:17 +0000
161@@ -0,0 +1,158 @@
162+# Copyright 2012 Canonical Ltd.
163+
164+# This file is part of lazr.restfulclient.
165+#
166+# lazr.restfulclient is free software: you can redistribute it and/or
167+# modify it under the terms of the GNU Lesser General Public License
168+# as published by the Free Software Foundation, version 3 of the
169+# License.
170+#
171+# lazr.restfulclient is distributed in the hope that it will be
172+# useful, but WITHOUT ANY WARRANTY; without even the implied warranty
173+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
174+# Lesser General Public License for more details.
175+#
176+# You should have received a copy of the GNU Lesser General Public License
177+# along with lazr.restfulclient. If not, see <http://www.gnu.org/licenses/>.
178+
179+"""Tests for the atomic file cache."""
180+
181+__metaclass__ = type
182+
183+import shutil
184+import tempfile
185+import unittest
186+
187+import httplib2
188+
189+from lazr.restfulclient._browser import AtomicFileCache
190+
191+
192+class TestFileCacheInterface(unittest.TestCase):
193+ """Tests for ``AtomicFileCache``."""
194+
195+ file_cache_factory = httplib2.FileCache
196+
197+ unicode_text = u'pa\u026a\u03b8\u0259n'
198+
199+ def setUp(self):
200+ super(TestFileCacheInterface, self).setUp()
201+ self.cache_dir = tempfile.mkdtemp()
202+
203+ def tearDown(self):
204+ shutil.rmtree(self.cache_dir)
205+ super(TestFileCacheInterface, self).tearDown()
206+
207+ def make_file_cache(self):
208+ """Make a FileCache-like object to be tested."""
209+ return self.file_cache_factory(self.cache_dir)
210+
211+ def test_get_non_existent_key(self):
212+ # get() returns None if the key does not exist.
213+ cache = self.make_file_cache()
214+ self.assertIs(None, cache.get('nonexistent'))
215+
216+ def test_set_key(self):
217+ # A key set with set() can be got by get().
218+ cache = self.make_file_cache()
219+ cache.set('key', 'value')
220+ self.assertEqual('value', cache.get('key'))
221+
222+ def test_set_twice_overrides(self):
223+ # Setting a key again overrides the value.
224+ cache = self.make_file_cache()
225+ cache.set('key', 'value')
226+ cache.set('key', 'new-value')
227+ self.assertEqual('new-value', cache.get('key'))
228+
229+ def test_delete_absent_key(self):
230+ # Deleting a key that's not there does nothing.
231+ cache = self.make_file_cache()
232+ cache.delete('nonexistent')
233+ self.assertIs(None, cache.get('nonexistent'))
234+
235+ def test_delete_key(self):
236+ # A key once set can be deleted. Further attempts to get that key
237+ # return None.
238+ cache = self.make_file_cache()
239+ cache.set('key', 'value')
240+ cache.delete('key')
241+ self.assertIs(None, cache.get('key'))
242+
243+ def test_get_non_string_key(self):
244+ # get() raises TypeError if asked to get a non-string key.
245+ cache = self.make_file_cache()
246+ self.assertRaises(TypeError, cache.get, 42)
247+
248+ def test_delete_non_string_key(self):
249+ # delete() raises TypeError if asked to delete a non-string key.
250+ cache = self.make_file_cache()
251+ self.assertRaises(TypeError, cache.delete, 42)
252+
253+ def test_set_non_string_key(self):
254+ # set() raises TypeError if asked to set a non-string key.
255+ cache = self.make_file_cache()
256+ self.assertRaises(TypeError, cache.set, 42, 'the answer')
257+
258+ def test_set_non_string_value(self):
259+ # set() raises TypeError if asked to set a key to a non-string value.
260+ # Attempts to retrieve that value return the empty string. This is
261+ # probably a bug in httplib2.FileCache.
262+ cache = self.make_file_cache()
263+ self.assertRaises(TypeError, cache.set, 'answer', 42)
264+ self.assertEqual('', cache.get('answer'))
265+
266+ def test_get_unicode(self):
267+ # get() can retrieve unicode keys.
268+ cache = self.make_file_cache()
269+ self.assertIs(None, cache.get(self.unicode_text))
270+
271+ def test_set_unicode_keys(self):
272+ cache = self.make_file_cache()
273+ cache.set(self.unicode_text, 'value')
274+ self.assertEqual('value', cache.get(self.unicode_text))
275+
276+ def test_set_unicode_value(self):
277+ # set() cannot store unicode values. Values must be bytes.
278+ cache = self.make_file_cache()
279+ self.assertRaises(
280+ UnicodeEncodeError, cache.set, 'key', self.unicode_text)
281+
282+ def test_delete_unicode(self):
283+ # delete() can remove unicode keys.
284+ cache = self.make_file_cache()
285+ cache.set(self.unicode_text, 'value')
286+ cache.delete(self.unicode_text)
287+ self.assertIs(None, cache.get(self.unicode_text))
288+
289+
290+class TestAtomicFileCache(TestFileCacheInterface):
291+ """Tests for ``AtomicFileCache``."""
292+
293+ file_cache_factory = AtomicFileCache
294+
295+ def test_set_non_string_value(self):
296+ # set() raises TypeError if asked to set a key to a non-string value.
297+ # Attempts to retrieve that value act is if it were never set.
298+ #
299+ # Note: This behaviour differs from httplib2.FileCache.
300+ cache = self.make_file_cache()
301+ self.assertRaises(TypeError, cache.set, 'answer', 42)
302+ self.assertIs(None, cache.get('answer'))
303+
304+ # Implementation-specific tests follow.
305+
306+ def test_bad_safename_get(self):
307+ safename = lambda x: AtomicFileCache.TEMPFILE_PREFIX + x
308+ cache = AtomicFileCache(self.cache_dir, safename)
309+ self.assertRaises(ValueError, cache.get, 'key')
310+
311+ def test_bad_safename_set(self):
312+ safename = lambda x: AtomicFileCache.TEMPFILE_PREFIX + x
313+ cache = AtomicFileCache(self.cache_dir, safename)
314+ self.assertRaises(ValueError, cache.set, 'key', 'value')
315+
316+ def test_bad_safename_delete(self):
317+ safename = lambda x: AtomicFileCache.TEMPFILE_PREFIX + x
318+ cache = AtomicFileCache(self.cache_dir, safename)
319+ self.assertRaises(ValueError, cache.delete, 'key')

Subscribers

People subscribed via source and target branches