Merge lp:~leonardr/launchpad/test-representation-cache into lp:launchpad

Proposed by Leonard Richardson
Status: Merged
Merged at revision: 11011
Proposed branch: lp:~leonardr/launchpad/test-representation-cache
Merge into: lp:launchpad
Diff against target: 323 lines (+220/-10)
8 files modified
lib/canonical/database/sqlbase.py (+7/-0)
lib/canonical/launchpad/pagetests/webservice/cache.txt (+55/-0)
lib/canonical/launchpad/zcml/webservice.zcml (+5/-0)
lib/lp/services/memcache/client.py (+3/-1)
lib/lp/services/memcache/doc/restful-cache.txt (+102/-0)
lib/lp/services/memcache/restful.py (+39/-0)
lib/lp/services/memcache/tests/test_doc.py (+8/-8)
versions.cfg (+1/-1)
To merge this branch: bzr merge lp:~leonardr/launchpad/test-representation-cache
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Brad Crittenden (community) code Approve
Review via email: mp+26513@code.launchpad.net

Description of the change

This branch integrates version 0.9.27 of lazr.restful to create a memcached-based cache for JSON representations of entries. In tests with a populated cache, (https://dev.launchpad.net/Foundations/Webservice/Performance#Store%20representations%20in%20memcached) this reduced the time to receive a representation of a collection by about 4x.

lazr.restful takes care of putting representations in the cache and retrieving them at appropriate times. This code takes care of invalidating the cache when a Storm object changes, and turning a Storm object into a unique cache key.

I plan to make one more change to this branch: make it easy to for the LOSAs to turn the cache off if it turns out to cause problems in production. I'm not really sure how to do this, so I'm presenting the branch as is as I work on this other problem.

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

Hi Leonard,

Thanks for this branch -- it looks very promising. A 4x increase would be great.

It looks like you haven't pushed your changes to the download cache up yet so lazr.restful 0.9.27 isn't available. Be sure to do that.

You define but don't use 'json_type' in webservice/cache.txt.

The __all__ in memcache/client.py should be on multiple lines.

It looks like there are some valid complaints from 'make lint'. Please run it and clean up the ones that are real.

review: Approve (code)
Revision history for this message
Stuart Bishop (stub) wrote :
Download full text (4.6 KiB)

On Tue, Jun 1, 2010 at 11:12 PM, Leonard Richardson
<email address hidden> wrote:

> === modified file 'lib/canonical/database/sqlbase.py'

> +    def __storm_flushed__(self):
> +        """Invalidate the web service cache."""
> +        cache = getUtility(IRepresentationCache)
> +        cache.delete(self)

This looks like a decent way of hooking in. Unfortunatly, SQLBase is
legacy - used by objects providing the SQLObject compatibility layer.
Some of our newer classes use the Storm base class directly. If there
is no way to register an on-flush hook with Storm, you will need to
create a Launchpad specific subclass of storm.Storm that provides this
hook, and update classes using the Storm base class directly to use
LPStorm.

The cache can become stale by:

1) Raw SQL, at psql command line or executed by appservers or scripts.
2) Stored procedures
3) Store.remove()
4) Using Storm for bulk updates, inserts or deletions.
5) Scripts or appservers running with non-identical [memcache] config section.
6) Scripts or appservers unable to contact memcached servers
7) Network glitches

We should probably ignore the last three. Are the first four a problem
though for the webservice? If it doesn't really matter, fine.
Otherwise we might need some ON DELETE and ON UPDATE database triggers
to keep things in sync. Hopefully this is unnecessary, as it could
will kill our performance gain.

=== added file 'lib/canonical/launchpad/pagetests/webservice/cache.txt'

+The cache starts out empty.
+
+ >>> print cache.get_by_key(key)
+ None
+
+Retrieving a representation of an object populates the cache.
+
+ >>> ignore = webservice.get("/~salgado", api_version="devel").jsonBody()
+
+ >>> cache.get_by_key(key)
+ '{...}'

Is webservice.get making a real connection to the webservice here? I
am curious if the cache is populated when the object is retrieved,
rather than when the transaction that retrieved the object commits.

=== added file 'lib/lp/services/memcache/doc/restful-cache.txt'

> +An object's cache key is derived from its Storm metadata: its database
> +table name and its primary key.

> +    >>> cache_key = cache.key_for(
> +    ...     person, 'media/type', 'web-service-version')
> +    >>> print person.id, cache_key
> +    29 Person(29,),media/type,web-service-version

Do we need the LPCONFIG here too? Or is it ok for edge & production to mix?

Hmm... perhaps IMemcacheClient should grow a real API and
automatically prepend the LPCONFIG - not sure if have a use case for
edge and production sharing data, but it is convenient to have them
share the same physical Memcached servers.

> +When a Launchpad object is modified, its JSON representations for
> +recognized web service versions are automatically removed from the
> +cache.
> +
> +    >>> person.addressline1 = "New address"
> +    >>> from canonical.launchpad.ftests import syncUpdate
> +    >>> syncUpdate(person)
> +
> +    >>> print cache.get(person, json_type, "beta", default="missing")
> +    missing
> +
> +    >>> print cache.get(person, json_type, "1.0", default="missing")
> +    missing

Should we document the cases where this doesn't happen, or is this
irrelevant to the webservice?
...

Read more...

Revision history for this message
Stuart Bishop (stub) :
review: Approve
Revision history for this message
Leonard Richardson (leonardr) wrote :
Download full text (4.5 KiB)

> 1) Raw SQL, at psql command line or executed by appservers or scripts.
> 2) Stored procedures
> 3) Store.remove()
> 4) Using Storm for bulk updates, inserts or deletions.
> 5) Scripts or appservers running with non-identical [memcache] config section.
> 6) Scripts or appservers unable to contact memcached servers
> 7) Network glitches
>
> We should probably ignore the last three. Are the first four a problem
> though for the webservice? If it doesn't really matter, fine.
> Otherwise we might need some ON DELETE and ON UPDATE database triggers
> to keep things in sync. Hopefully this is unnecessary, as it could
> will kill our performance gain.

Certainly the first four are a problem. So far we have yet to come to
terms with performance improvements that involve serving stale data from
the web service (or allowing users to use the possibly stale data they
already have).

Can we put the invalidation code in the ON DELETE/ON UPDATE trigger,
rather than in the Storm trigger? Would it hurt performance to just move
the invalidation down a layer?

> + >>> ignore = webservice.get("/~salgado", api_version="devel").jsonBody()
> +
> + >>> cache.get_by_key(key)
> + '{...}'
>
> Is webservice.get making a real connection to the webservice here? I
> am curious if the cache is populated when the object is retrieved,
> rather than when the transaction that retrieved the object commits.

An HTTP request is being constructed and through Python machinations it
is invoking the web service code. But nothing's going over the network.

The cache is populated by the web service code during the GET handling.
Cache population shouldn't have anything to do with the database.

I hope this answers your question.

> > + >>> cache_key = cache.key_for(
> > + ... person, 'media/type', 'web-service-version')
> > + >>> print person.id, cache_key
> > + 29 Person(29,),media/type,web-service-version
>
> Do we need the LPCONFIG here too? Or is it ok for edge & production to mix?
>
> Hmm... perhaps IMemcacheClient should grow a real API and
> automatically prepend the LPCONFIG - not sure if have a use case for
> edge and production sharing data, but it is convenient to have them
> share the same physical Memcached servers.

Aaaah, it's absolutely not OK for edge and production to mix data. The
JSON representations include http: links to one server or the other. I
can fix this in the branch by tacking the instance name on to the cache
key.

> Should we document the cases where this doesn't happen, or is this
> irrelevant to the webservice?
>
> store.execute(Update({Person.addressline1: 'Newer Address'},
> Person.name==person.name)) should make the cache stale.

I'd say that's relevant, and another argument for putting the cache
invalidation in a database hook rather than an ORM hook.

>
> > === added file 'lib/lp/services/memcache/restful.py'
>
> > +class MemcachedStormRepresentationCache(BaseRepresentationCache):
> > + """Caches lazr.restful representations of Storm objects in memcached."""
> > +
> > + def __init__(self):
> > + self.client = memcache_client_factory()
>
> You should be using getUtility(IMemcacheClient) here - otherwise we
> end...

Read more...

Revision history for this message
Stuart Bishop (stub) wrote :
Download full text (5.2 KiB)

On Wed, Jun 2, 2010 at 7:28 PM, Leonard Richardson
<email address hidden> wrote:
>> 1) Raw SQL, at psql command line or executed by appservers or scripts.
>> 2) Stored procedures
>> 3) Store.remove()
>> 4) Using Storm for bulk updates, inserts or deletions.
>> 5) Scripts or appservers running with non-identical [memcache] config section.
>> 6) Scripts or appservers unable to contact memcached servers
>> 7) Network glitches
>>
>> We should probably ignore the last three. Are the first four a problem
>> though for the webservice? If it doesn't really matter, fine.
>> Otherwise we might need some ON DELETE and ON UPDATE database triggers
>> to keep things in sync. Hopefully this is unnecessary, as it could
>> will kill our performance gain.
>
> Certainly the first four are a problem. So far we have yet to come to
> terms with performance improvements that involve serving stale data from
> the web service (or allowing users to use the possibly stale data they
> already have).
>
> Can we put the invalidation code in the ON DELETE/ON UPDATE trigger,
> rather than in the Storm trigger? Would it hurt performance to just move
> the invalidation down a layer?

We can put the invalidation code in a trigger.

It will certainly hurt performance. How much, I'm not sure. It also
depends on how much effort we put into minimizing it. The stored
procedure will need to be invoked for every row updated or deleted.

The simplest approach would be a Python stored procedure for the
trigger that invalidates the cache in band. This would be huge
overhead, both the Python overhead and waiting for network round
trips.

A better approach would be for the trigger to notify another process
of the keys that need invalidating, and have that process do it
asynchronously. This would be less overhead than our existing Slony-I
replication triggers. There would be some lag.

An alternative, which I'd need to investigate, would be to piggy back
on the existing Slony-I replication information. Whenever a row is
updated or deleted, we generate events containing this information so
the changes can be applied on the subscriber database nodes. There
would be some lag here too, but no measurable database impact.

>> + >>> ignore = webservice.get("/~salgado", api_version="devel").jsonBody()
>> +
>> + >>> cache.get_by_key(key)
>> + '{...}'
>>
>> Is webservice.get making a real connection to the webservice here? I
>> am curious if the cache is populated when the object is retrieved,
>> rather than when the transaction that retrieved the object commits.
>
> An HTTP request is being constructed and through Python machinations it
> is invoking the web service code. But nothing's going over the network.
>
> The cache is populated by the web service code during the GET handling.
> Cache population shouldn't have anything to do with the database.
>
> I hope this answers your question.

I'm wondering if it is possible that the cache gets populated, but the
webservice request fails, causing an invalid value to get stored. If
we don't populate the cache on updates then it should be fine.

>> > === added file 'lib/lp/services/memcache/restful.py'
>>
>> > +class MemcachedStormRepresen...

Read more...

Revision history for this message
Leonard Richardson (leonardr) wrote :

> A better approach would be for the trigger to notify another process
> of the keys that need invalidating, and have that process do it
> asynchronously. This would be less overhead than our existing Slony-I
> replication triggers. There would be some lag.
>
> An alternative, which I'd need to investigate, would be to piggy back
> on the existing Slony-I replication information. Whenever a row is
> updated or deleted, we generate events containing this information so
> the changes can be applied on the subscriber database nodes. There
> would be some lag here too, but no measurable database impact.

I'm +1 on either of these ideas. The only downside is that the invalidation algorithm will become more complex over time, as I make lazr.restful cache more things and as we create more versions of the Launchpad web service.

Here's what I mean. Right now when User(29,) is invalidated we need to remove the following three cache keys:

User(29,),application/json,beta
User(29,),application/json,1.0
User(29,),application/json,devel

That's one key for every web service version. We need to have access to a list of the currently active versions, either managed separately or obtained from getUtility(IWebServiceConfiguration).active_Versions.

You also brought up the fact that all our Launchpad instances share memcached hardware. So we need to change Launchpad's key generation to include the instance name. And we need to change the invalidation code to have a list of all instance names, and to invalidate these nine keys:

User(29,),application/json,beta,edge
User(29,),application/json,beta,staging
User(29,),application/json,beta,production
User(29,),application/json,1.0,edge
User(29,),application/json,1.0,staging
User(29,),application/json,1.0,production
User(29,),application/json,devel,edge
User(29,),application/json,devel,staging
User(29,),application/json,devel,production

In the future we may change lazr.restful to cache the WADL and/or HTML representations as well as the JSON representation. In that case there could be up to 27 cache keys for User(29,).

This isn't unmanageable, but it would be much simpler if we could tell memcached to delete "User(29,).*"

> I'm wondering if it is possible that the cache gets populated, but the
> webservice request fails, causing an invalid value to get stored. If
> we don't populate the cache on updates then it should be fine.

I'm pretty sure the cache is populated right before the representation is returned to the client. If there's an error it will happen during the representation creation. If for some reason an error happens afterwards it doesn't change the fact that the representation is accurate. The cache is certainly not populated on updates. So I think we're OK.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/database/sqlbase.py'
2--- lib/canonical/database/sqlbase.py 2010-03-24 17:37:26 +0000
3+++ lib/canonical/database/sqlbase.py 2010-06-01 20:53:29 +0000
4@@ -28,6 +28,8 @@
5 from zope.interface import implements
6 from zope.security.proxy import removeSecurityProxy
7
8+from lazr.restful.interfaces import IRepresentationCache
9+
10 from canonical.config import config, dbconfig
11 from canonical.database.interfaces import ISQLBase
12
13@@ -246,6 +248,11 @@
14 """Inverse of __eq__."""
15 return not (self == other)
16
17+ def __storm_flushed__(self):
18+ """Invalidate the web service cache."""
19+ cache = getUtility(IRepresentationCache)
20+ cache.delete(self)
21+
22 alreadyInstalledMsg = ("A ZopelessTransactionManager with these settings is "
23 "already installed. This is probably caused by calling initZopeless twice.")
24
25
26=== added file 'lib/canonical/launchpad/pagetests/webservice/cache.txt'
27--- lib/canonical/launchpad/pagetests/webservice/cache.txt 1970-01-01 00:00:00 +0000
28+++ lib/canonical/launchpad/pagetests/webservice/cache.txt 2010-06-01 20:53:29 +0000
29@@ -0,0 +1,55 @@
30+************************
31+The representation cache
32+************************
33+
34+Launchpad stores JSON representations of objects in a memcached
35+cache. The full cache functionality is tested in lazr.restful and in
36+lib/lp/services/memcache/doc/restful-cache.txt. This is just a simple
37+integration test.
38+
39+First we need to get a reference to the cache object, so we can look
40+inside.
41+
42+ >>> from zope.component import getUtility
43+ >>> from lazr.restful.interfaces import IRepresentationCache
44+ >>> cache = getUtility(IRepresentationCache)
45+
46+Since the cache is keyed by the underlying database object, we also
47+need one of those objects.
48+
49+ >>> from lp.registry.interfaces.person import IPersonSet
50+ >>> login(ANONYMOUS)
51+ >>> person = getUtility(IPersonSet).getByName('salgado')
52+ >>> key = cache.key_for(person, 'application/json', 'devel')
53+ >>> logout()
54+
55+The cache starts out empty.
56+
57+ >>> print cache.get_by_key(key)
58+ None
59+
60+Retrieving a representation of an object populates the cache.
61+
62+ >>> ignore = webservice.get("/~salgado", api_version="devel").jsonBody()
63+
64+ >>> cache.get_by_key(key)
65+ '{...}'
66+
67+Once the cache is populated with a representation, the cached
68+representation is used in preference to generating a new
69+representation of that object. We can verify this by putting a fake
70+value into the cache and retrieving a representation of the
71+corresponding object.
72+
73+ >>> import simplejson
74+ >>> cache.set_by_key(key, simplejson.dumps("Fake representation"))
75+
76+ >>> print webservice.get("/~salgado", api_version="devel").jsonBody()
77+ Fake representation
78+
79+Cleanup.
80+
81+ >>> cache.delete(person)
82+
83+ >>> webservice.get("/~salgado", api_version="devel").jsonBody()
84+ {...}
85
86=== modified file 'lib/canonical/launchpad/zcml/webservice.zcml'
87--- lib/canonical/launchpad/zcml/webservice.zcml 2010-03-26 19:11:50 +0000
88+++ lib/canonical/launchpad/zcml/webservice.zcml 2010-06-01 20:53:29 +0000
89@@ -16,6 +16,11 @@
90 provides="lazr.restful.interfaces.IWebServiceConfiguration">
91 </utility>
92
93+ <utility
94+ factory="lp.services.memcache.restful.MemcachedStormRepresentationCache"
95+ provides="lazr.restful.interfaces.IRepresentationCache">
96+ </utility>
97+
98 <securedutility
99 class="canonical.launchpad.systemhomes.WebServiceApplication"
100 provides="canonical.launchpad.interfaces.IWebServiceApplication">
101
102=== modified file 'lib/lp/services/memcache/client.py'
103--- lib/lp/services/memcache/client.py 2009-09-16 12:47:23 +0000
104+++ lib/lp/services/memcache/client.py 2010-06-01 20:53:29 +0000
105@@ -4,7 +4,9 @@
106 """Launchpad Memcache client."""
107
108 __metaclass__ = type
109-__all__ = []
110+__all__ = [
111+ 'memcache_client_factory'
112+ ]
113
114 import memcache
115 import re
116
117=== added file 'lib/lp/services/memcache/doc/restful-cache.txt'
118--- lib/lp/services/memcache/doc/restful-cache.txt 1970-01-01 00:00:00 +0000
119+++ lib/lp/services/memcache/doc/restful-cache.txt 2010-06-01 20:53:29 +0000
120@@ -0,0 +1,102 @@
121+***************************************
122+The Storm/memcached representation cache
123+****************************************
124+
125+The web service library lazr.restful will store the representations it
126+generates in a cache, if a suitable cache implementation is
127+provided. We implement a cache that stores representations of Storm
128+objects in memcached.
129+
130+ >>> login('foo.bar@canonical.com')
131+
132+ >>> from lp.services.memcache.restful import (
133+ ... MemcachedStormRepresentationCache)
134+ >>> cache = MemcachedStormRepresentationCache()
135+
136+An object's cache key is derived from its Storm metadata: its database
137+table name and its primary key.
138+
139+ >>> from zope.component import getUtility
140+ >>> from lp.registry.interfaces.person import IPersonSet
141+ >>> person = getUtility(IPersonSet).getByName('salgado')
142+
143+ >>> cache_key = cache.key_for(
144+ ... person, 'media/type', 'web-service-version')
145+ >>> print person.id, cache_key
146+ 29 Person(29,),media/type,web-service-version
147+
148+ >>> from operator import attrgetter
149+ >>> languages = sorted(person.languages, key=attrgetter('englishname'))
150+ >>> for language in languages:
151+ ... cache_key = cache.key_for(
152+ ... language, 'media/type', 'web-service-version')
153+ ... print language.id, cache_key
154+ 119 Language(119,),media/type,web-service-version
155+ 521 Language(521,),media/type,web-service-version
156+
157+The cache starts out empty.
158+
159+ >>> json_type = 'application/json'
160+
161+ >>> print cache.get(person, json_type, "v1", default="missing")
162+ missing
163+
164+Add a representation to the cache, and you can retrieve it later.
165+
166+ >>> cache.set(person, json_type, "beta",
167+ ... "This is a representation for version beta.")
168+
169+ >>> print cache.get(person, json_type, "beta")
170+ This is a representation for version beta.
171+
172+A single object can cache different representations for different
173+web service versions.
174+
175+ >>> cache.set(person, json_type, '1.0',
176+ ... 'This is a different representation for version 1.0.')
177+
178+ >>> print cache.get(person, json_type, "1.0")
179+ This is a different representation for version 1.0.
180+
181+The web service version doesn't have to actually be defined in the
182+configuration. (But you shouldn't use this--see below!)
183+
184+ >>> cache.set(person, json_type, 'no-such-version',
185+ ... 'This is a representation for a nonexistent version.')
186+
187+ >>> print cache.get(person, json_type, "no-such-version")
188+ This is a representation for a nonexistent version.
189+
190+A single object can also cache different representations for different
191+media types, not just application/json. (But you shouldn't use
192+this--see below!)
193+
194+ >>> cache.set(person, 'media/type', '1.0',
195+ ... 'This is a representation for a strange media type.')
196+
197+ >>> print cache.get(person, "media/type", "1.0")
198+ This is a representation for a strange media type.
199+
200+When a Launchpad object is modified, its JSON representations for
201+recognized web service versions are automatically removed from the
202+cache.
203+
204+ >>> person.addressline1 = "New address"
205+ >>> from canonical.launchpad.ftests import syncUpdate
206+ >>> syncUpdate(person)
207+
208+ >>> print cache.get(person, json_type, "beta", default="missing")
209+ missing
210+
211+ >>> print cache.get(person, json_type, "1.0", default="missing")
212+ missing
213+
214+But non-JSON representations, and representations for unrecognized web
215+service versions, are _not_ removed from the cache. (This is why you
216+shouldn't put such representations into the cache.)
217+
218+ >>> print cache.get(person, json_type, "no-such-version")
219+ This is a representation for a nonexistent version.
220+
221+ >>> print cache.get(person, "media/type", "1.0")
222+ This is a representation for a strange media type.
223
224=== added file 'lib/lp/services/memcache/restful.py'
225--- lib/lp/services/memcache/restful.py 1970-01-01 00:00:00 +0000
226+++ lib/lp/services/memcache/restful.py 2010-06-01 20:53:29 +0000
227@@ -0,0 +1,39 @@
228+# Copyright 2010 Canonical Ltd. This software is licensed under the
229+# GNU Affero General Public License version 3 (see the file LICENSE).
230+
231+"""Storm/memcached implementation of lazr.restful's representation cache."""
232+
233+import storm
234+
235+from lp.services.memcache.client import memcache_client_factory
236+from lazr.restful.simple import BaseRepresentationCache
237+
238+__metaclass__ = type
239+__all__ = [
240+ 'MemcachedStormRepresentationCache',
241+]
242+
243+
244+class MemcachedStormRepresentationCache(BaseRepresentationCache):
245+ """Caches lazr.restful representations of Storm objects in memcached."""
246+
247+ def __init__(self):
248+ self.client = memcache_client_factory()
249+
250+ def key_for(self, obj, media_type, version):
251+ storm_info = storm.info.get_obj_info(obj)
252+ table_name = storm_info.cls_info.table
253+ primary_key = tuple(var.get() for var in storm_info.primary_vars)
254+
255+ key = (table_name + repr(primary_key)
256+ + ',' + media_type + ',' + str(version))
257+ return key
258+
259+ def get_by_key(self, key, default=None):
260+ return self.client.get(key) or default
261+
262+ def set_by_key(self, key, value):
263+ self.client.set(key, value)
264+
265+ def delete_by_key(self, key):
266+ self.client.delete(key)
267
268=== modified file 'lib/lp/services/memcache/tests/test_doc.py'
269--- lib/lp/services/memcache/tests/test_doc.py 2010-03-03 11:00:42 +0000
270+++ lib/lp/services/memcache/tests/test_doc.py 2010-06-01 20:53:29 +0000
271@@ -7,9 +7,7 @@
272
273 import os.path
274 from textwrap import dedent
275-import unittest
276
277-from zope.component import getUtility
278 import zope.pagetemplate.engine
279 from zope.pagetemplate.pagetemplate import PageTemplate
280 from zope.publisher.browser import TestRequest
281@@ -17,9 +15,7 @@
282 from canonical.launchpad.testing.systemdocs import (
283 LayeredDocFileSuite, setUp, tearDown)
284 from canonical.testing.layers import LaunchpadFunctionalLayer, MemcachedLayer
285-from lp.services.memcache.interfaces import IMemcacheClient
286 from lp.services.testing import build_test_suite
287-from lp.testing import TestCase
288
289
290 here = os.path.dirname(os.path.realpath(__file__))
291@@ -59,11 +55,15 @@
292 test.globs['MemcachedLayer'] = MemcachedLayer
293
294
295+def suite_for_doctest(filename):
296+ return LayeredDocFileSuite(
297+ '../doc/%s' % filename,
298+ setUp=memcacheSetUp, tearDown=tearDown,
299+ layer=LaunchpadFunctionalLayer)
300+
301 special = {
302- 'tales-cache.txt': LayeredDocFileSuite(
303- '../doc/tales-cache.txt',
304- setUp=memcacheSetUp, tearDown=tearDown,
305- layer=LaunchpadFunctionalLayer),
306+ 'tales-cache.txt': suite_for_doctest('tales-cache.txt'),
307+ 'restful-cache.txt': suite_for_doctest('restful-cache.txt'),
308 }
309
310
311
312=== modified file 'versions.cfg'
313--- versions.cfg 2010-05-17 20:03:02 +0000
314+++ versions.cfg 2010-06-01 20:53:29 +0000
315@@ -28,7 +28,7 @@
316 lazr.delegates = 1.1.0
317 lazr.enum = 1.1.2
318 lazr.lifecycle = 1.1
319-lazr.restful = 0.9.26
320+lazr.restful = 0.9.27
321 lazr.restfulclient = 0.9.14
322 lazr.smtptest = 1.1
323 lazr.testing = 0.1.1