Merge lp:~lifeless/launchpad/cachedproperty into lp:launchpad

Proposed by Robert Collins
Status: Merged
Approved by: Michael Hudson-Doyle
Approved revision: no longer in the source branch.
Merged at revision: 11366
Proposed branch: lp:~lifeless/launchpad/cachedproperty
Merge into: lp:launchpad
Diff against target: 419 lines (+179/-20)
9 files modified
lib/canonical/cachedproperty.py (+145/-4)
lib/canonical/database/sqlbase.py (+12/-0)
lib/canonical/launchpad/database/librarian.py (+1/-0)
lib/lp/registry/doc/teammembership.txt (+2/-1)
lib/lp/registry/model/distribution.py (+4/-3)
lib/lp/registry/model/person.py (+10/-10)
lib/lp/registry/model/product.py (+1/-2)
lib/lp/translations/model/potemplate.py (+1/-0)
lib/lp/translations/model/potmsgset.py (+3/-0)
To merge this branch: bzr merge lp:~lifeless/launchpad/cachedproperty
Reviewer Review Type Date Requested Status
Michael Hudson-Doyle Approve
Review via email: mp+32728@code.launchpad.net

Commit message

Automatically clear cachedproperties on SQLBase derived classes when storm invalidates an object.

Description of the change

Make invalidation of cached properties on SQLBase derived classes automatic. This addresses many of the risks associated with using them in the test suite (the server already discards all such objects between transactions), leaving just read-write-readback cases to handle as and when they occur.

To post a comment you must log in.
Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

Hi,

This looks good. I'm not sure what value there is in initializing _cached_properties in SQLBase.__init__ is -- seems to slightly increase coupling.

Generally, yay for safer-by-default.

Cheers,
mwh

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Is there or will there be a similar mechanism for pure Storm classes? Otherwise this adds a nasty pitfall to Storm migration.

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

On Mon, Aug 16, 2010 at 3:51 PM, Jeroen T. Vermeulen <email address hidden> wrote:
> Is there or will there be a similar mechanism for pure Storm classes?  Otherwise this adds a nasty pitfall to Storm migration.

Its a two liner:

def __storm_invalidate(self):
    clear_cachedproperties(self)

SQLBase is storm to all intents and purposes these days.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/cachedproperty.py'
2--- lib/canonical/cachedproperty.py 2010-01-11 21:29:33 +0000
3+++ lib/canonical/cachedproperty.py 2010-08-17 01:31:02 +0000
4@@ -3,10 +3,24 @@
5
6 """Cached properties for situations where a property is computed once and
7 then returned each time it is asked for.
8+
9+The clear_cachedproperties function can be used to wipe the cache of properties
10+from an instance.
11 """
12
13 __metaclass__ = type
14
15+__all__ = [
16+ 'cache_property',
17+ 'cachedproperty',
18+ 'clear_cachedproperties',
19+ 'clear_property',
20+ ]
21+
22+from zope.security.proxy import removeSecurityProxy
23+
24+from canonical.lazr.utils import safe_hasattr
25+
26 # XXX: JonathanLange 2010-01-11 bug=505731: Move this to lp.services.
27
28 def cachedproperty(attrname_or_fn):
29@@ -17,6 +31,10 @@
30
31 If you don't provide a name, the mangled name of the property is used.
32
33+ cachedproperty is not threadsafe - it should not be used on objects which
34+ are shared across threads / external locking should be used on those
35+ objects.
36+
37 >>> class CachedPropertyTest(object):
38 ...
39 ... @cachedproperty('_foo_cache')
40@@ -44,6 +62,10 @@
41 69
42 >>> cpt._bar_cached_value
43 69
44+
45+ Cached properties are listed on instances.
46+ >>> sorted(cpt._cached_properties)
47+ ['_bar_cached_value', '_foo_cache']
48
49 """
50 if isinstance(attrname_or_fn, basestring):
51@@ -54,8 +76,125 @@
52 attrname = '_%s_cached_value' % fn.__name__
53 return CachedProperty(attrname, fn)
54
55+def cache_property(instance, attrname, value):
56+ """Cache value on instance as attrname.
57+
58+ instance._cached_properties is updated with attrname.
59+
60+ >>> class CachedPropertyTest(object):
61+ ...
62+ ... @cachedproperty('_foo_cache')
63+ ... def foo(self):
64+ ... return 23
65+ ...
66+ >>> instance = CachedPropertyTest()
67+ >>> cache_property(instance, '_foo_cache', 42)
68+ >>> instance.foo
69+ 42
70+ >>> instance._cached_properties
71+ ['_foo_cache']
72+
73+ Caching a new value does not duplicate the cache keys.
74+
75+ >>> cache_property(instance, '_foo_cache', 84)
76+ >>> instance._cached_properties
77+ ['_foo_cache']
78+
79+ And does update the cached value.
80+
81+ >>> instance.foo
82+ 84
83+ """
84+ naked_instance = removeSecurityProxy(instance)
85+ clear_property(naked_instance, attrname)
86+ setattr(naked_instance, attrname, value)
87+ cached_properties = getattr(naked_instance, '_cached_properties', [])
88+ cached_properties.append(attrname)
89+ naked_instance._cached_properties = cached_properties
90+
91+
92+def clear_property(instance, attrname):
93+ """Remove a cached attribute from instance.
94+
95+ The attribute name is removed from instance._cached_properties.
96+
97+ If the property is not cached, nothing happens.
98+
99+ :seealso clear_cachedproperties: For clearing all cached items at once.
100+
101+ >>> class CachedPropertyTest(object):
102+ ...
103+ ... @cachedproperty('_foo_cache')
104+ ... def foo(self):
105+ ... return 23
106+ ...
107+ >>> instance = CachedPropertyTest()
108+ >>> instance.foo
109+ 23
110+ >>> clear_property(instance, '_foo_cache')
111+ >>> instance._cached_properties
112+ []
113+ >>> is_cached(instance, '_foo_cache')
114+ False
115+ >>> clear_property(instance, '_foo_cache')
116+ """
117+ naked_instance = removeSecurityProxy(instance)
118+ if not is_cached(naked_instance, attrname):
119+ return
120+ delattr(naked_instance, attrname)
121+ naked_instance._cached_properties.remove(attrname)
122+
123+
124+def clear_cachedproperties(instance):
125+ """Clear cached properties from an object.
126+
127+ >>> class CachedPropertyTest(object):
128+ ...
129+ ... @cachedproperty('_foo_cache')
130+ ... def foo(self):
131+ ... return 23
132+ ...
133+ >>> instance = CachedPropertyTest()
134+ >>> instance.foo
135+ 23
136+ >>> instance._cached_properties
137+ ['_foo_cache']
138+ >>> clear_cachedproperties(instance)
139+ >>> instance._cached_properties
140+ []
141+ >>> hasattr(instance, '_foo_cache')
142+ False
143+ """
144+ naked_instance = removeSecurityProxy(instance)
145+ cached_properties = getattr(naked_instance, '_cached_properties', [])
146+ for property_name in cached_properties:
147+ delattr(naked_instance, property_name)
148+ naked_instance._cached_properties = []
149+
150+
151+def is_cached(instance, attrname):
152+ """Return True if attrname is cached on instance.
153+
154+ >>> class CachedPropertyTest(object):
155+ ...
156+ ... @cachedproperty('_foo_cache')
157+ ... def foo(self):
158+ ... return 23
159+ ...
160+ >>> instance = CachedPropertyTest()
161+ >>> instance.foo
162+ 23
163+ >>> is_cached(instance, '_foo_cache')
164+ True
165+ >>> is_cached(instance, '_var_cache')
166+ False
167+ """
168+ naked_instance = removeSecurityProxy(instance)
169+ return safe_hasattr(naked_instance, attrname)
170+
171
172 class CachedPropertyForAttr:
173+ """Curry a decorator to provide arguments to the CachedProperty."""
174
175 def __init__(self, attrname):
176 self.attrname = attrname
177@@ -66,18 +205,20 @@
178
179 class CachedProperty:
180
181+ # Used to detect not-yet-cached properties.
182+ sentinel = object()
183+
184 def __init__(self, attrname, fn):
185 self.fn = fn
186 self.attrname = attrname
187- self.marker = object()
188
189 def __get__(self, inst, cls=None):
190 if inst is None:
191 return self
192- cachedresult = getattr(inst, self.attrname, self.marker)
193- if cachedresult is self.marker:
194+ cachedresult = getattr(inst, self.attrname, CachedProperty.sentinel)
195+ if cachedresult is CachedProperty.sentinel:
196 result = self.fn(inst)
197- setattr(inst, self.attrname, result)
198+ cache_property(inst, self.attrname, result)
199 return result
200 else:
201 return cachedresult
202
203=== modified file 'lib/canonical/database/sqlbase.py'
204--- lib/canonical/database/sqlbase.py 2010-05-21 13:08:18 +0000
205+++ lib/canonical/database/sqlbase.py 2010-08-17 01:31:02 +0000
206@@ -30,6 +30,7 @@
207
208 from lazr.restful.interfaces import IRepresentationCache
209
210+from canonical.cachedproperty import clear_cachedproperties
211 from canonical.config import config, dbconfig
212 from canonical.database.interfaces import ISQLBase
213
214@@ -175,6 +176,9 @@
215 correct master Store.
216 """
217 from canonical.launchpad.interfaces import IMasterStore
218+ # Make it simple to write dumb-invalidators - initialised
219+ # _cached_properties to a valid list rather than just-in-time creation.
220+ self._cached_properties = []
221 store = IMasterStore(self.__class__)
222
223 # The constructor will fail if objects from a different Store
224@@ -253,6 +257,14 @@
225 cache = getUtility(IRepresentationCache)
226 cache.delete(self)
227
228+ def __storm_invalidated__(self):
229+ """Flush cached properties."""
230+ # Note this is not directly tested; but the entire test suite blows up
231+ # awesomely if its broken : its entirely unclear where tests for this
232+ # should be -- RBC 20100816.
233+ clear_cachedproperties(self)
234+
235+
236 alreadyInstalledMsg = ("A ZopelessTransactionManager with these settings is "
237 "already installed. This is probably caused by calling initZopeless twice.")
238
239
240=== modified file 'lib/canonical/launchpad/database/librarian.py'
241--- lib/canonical/launchpad/database/librarian.py 2010-07-06 16:12:46 +0000
242+++ lib/canonical/launchpad/database/librarian.py 2010-08-17 01:31:02 +0000
243@@ -194,6 +194,7 @@
244
245 def __storm_invalidated__(self):
246 """Make sure that the file is closed across transaction boundary."""
247+ super(LibraryFileAlias, self).__storm_invalidated__()
248 self.close()
249
250
251
252=== modified file 'lib/lp/registry/doc/teammembership.txt'
253--- lib/lp/registry/doc/teammembership.txt 2010-07-14 15:28:42 +0000
254+++ lib/lp/registry/doc/teammembership.txt 2010-08-17 01:31:02 +0000
255@@ -1007,7 +1007,8 @@
256 >>> from canonical.launchpad.interfaces import IMasterObject
257 >>> IMasterObject(bad_user.account).status = AccountStatus.SUSPENDED
258 >>> IMasterObject(bad_user.preferredemail).status = EmailAddressStatus.OLD
259- >>> removeSecurityProxy(bad_user)._preferredemail_cached = None
260+ >>> from canonical.cachedproperty import clear_property
261+ >>> clear_property(removeSecurityProxy(bad_user), '_preferredemail_cached')
262 >>> transaction.commit()
263
264 >>> [m.displayname for m in t3.allmembers]
265
266=== modified file 'lib/lp/registry/model/distribution.py'
267--- lib/lp/registry/model/distribution.py 2010-08-11 02:17:14 +0000
268+++ lib/lp/registry/model/distribution.py 2010-08-17 01:31:02 +0000
269@@ -19,7 +19,7 @@
270 from zope.interface import alsoProvides, implements
271
272 from lp.archivepublisher.debversion import Version
273-from canonical.cachedproperty import cachedproperty
274+from canonical.cachedproperty import cachedproperty, clear_property
275 from canonical.database.constants import UTC_NOW
276
277 from canonical.database.datetimecol import UtcDateTimeCol
278@@ -1617,8 +1617,9 @@
279 # This driver is a release manager.
280 series.driver = owner
281
282- if safe_hasattr(self, '_cached_series'):
283- del self._cached_series
284+ # May wish to add this to the series rather than clearing the cache --
285+ # RBC 20100816.
286+ clear_property(self, '_cached_series')
287 return series
288
289 @property
290
291=== modified file 'lib/lp/registry/model/person.py'
292--- lib/lp/registry/model/person.py 2010-08-13 08:26:15 +0000
293+++ lib/lp/registry/model/person.py 2010-08-17 01:31:02 +0000
294@@ -59,7 +59,7 @@
295 from canonical.database.sqlbase import (
296 cursor, quote, quote_like, sqlvalues, SQLBase)
297
298-from canonical.cachedproperty import cachedproperty
299+from canonical.cachedproperty import cachedproperty, cache_property, clear_property
300
301 from canonical.lazr.utils import get_current_browser_request, safe_hasattr
302
303@@ -390,13 +390,12 @@
304
305 Order them by name if necessary.
306 """
307- self._languages_cache = sorted(
308- languages, key=attrgetter('englishname'))
309+ cache_property(self, '_languages_cache', sorted(
310+ languages, key=attrgetter('englishname')))
311
312 def deleteLanguagesCache(self):
313 """Delete this person's cached languages, if it exists."""
314- if safe_hasattr(self, '_languages_cache'):
315- del self._languages_cache
316+ clear_property(self, '_languages_cache')
317
318 def addLanguage(self, language):
319 """See `IPerson`."""
320@@ -1462,7 +1461,7 @@
321 include_teams=include_teams)
322 person_list = []
323 for person, email in result:
324- person._preferredemail_cached = email
325+ cache_property(person, '_preferredemail_cached', email)
326 person_list.append(person)
327 return person_list
328
329@@ -1737,7 +1736,7 @@
330 self.account_status = AccountStatus.DEACTIVATED
331 self.account_status_comment = comment
332 IMasterObject(self.preferredemail).status = EmailAddressStatus.NEW
333- self._preferredemail_cached = None
334+ clear_property(self, '_preferredemail_cached')
335 base_new_name = self.name + '-deactivatedaccount'
336 self.name = self._ensureNewName(base_new_name)
337
338@@ -2070,7 +2069,7 @@
339 if email_address is not None:
340 email_address.status = EmailAddressStatus.VALIDATED
341 email_address.syncUpdate()
342- self._preferredemail_cached = None
343+ clear_property(self, '_preferredemail_cached')
344
345 def setPreferredEmail(self, email):
346 """See `IPerson`."""
347@@ -2107,7 +2106,7 @@
348 IMasterObject(email).syncUpdate()
349
350 # Now we update our cache of the preferredemail.
351- self._preferredemail_cached = email
352+ cache_property(self, '_preferredemail_cached', email)
353
354 @cachedproperty('_preferredemail_cached')
355 def preferredemail(self):
356@@ -2622,7 +2621,8 @@
357 # Populate the previously empty 'preferredemail' cached
358 # property, so the Person record is up-to-date.
359 if master_email.status == EmailAddressStatus.PREFERRED:
360- account_person._preferredemail_cached = master_email
361+ cache_property(account_person, '_preferredemail_cached',
362+ master_email)
363 return account_person
364 # There is no associated `Person` to the email `Account`.
365 # This is probably because the account was created externally
366
367=== modified file 'lib/lp/registry/model/product.py'
368--- lib/lp/registry/model/product.py 2010-08-06 14:49:35 +0000
369+++ lib/lp/registry/model/product.py 2010-08-17 01:31:02 +0000
370@@ -509,9 +509,8 @@
371
372 def __storm_invalidated__(self):
373 """Clear cached non-storm attributes when the transaction ends."""
374+ super(Product, self).__storm_invalidated__()
375 self._cached_licenses = None
376- if safe_hasattr(self, '_commercial_subscription_cached'):
377- del self._commercial_subscription_cached
378
379 def _getLicenses(self):
380 """Get the licenses as a tuple."""
381
382=== modified file 'lib/lp/translations/model/potemplate.py'
383--- lib/lp/translations/model/potemplate.py 2010-08-06 09:32:02 +0000
384+++ lib/lp/translations/model/potemplate.py 2010-08-17 01:31:02 +0000
385@@ -185,6 +185,7 @@
386 _uses_english_msgids = None
387
388 def __storm_invalidated__(self):
389+ super(POTemplate, self).__storm_invalidated__()
390 self.clearPOFileCache()
391 self._uses_english_msgids = None
392
393
394=== modified file 'lib/lp/translations/model/potmsgset.py'
395--- lib/lp/translations/model/potmsgset.py 2010-08-06 06:51:37 +0000
396+++ lib/lp/translations/model/potmsgset.py 2010-08-17 01:31:02 +0000
397@@ -103,6 +103,7 @@
398 credits_message_ids = credits_message_info.keys()
399
400 def __storm_invalidated__(self):
401+ super(POTMsgSet, self).__storm_invalidated__()
402 self._cached_singular_text = None
403 self._cached_uses_english_msgids = None
404
405@@ -157,6 +158,7 @@
406 @property
407 def uses_english_msgids(self):
408 """See `IPOTMsgSet`."""
409+ # TODO: convert to cachedproperty, it will be simpler.
410 if self._cached_uses_english_msgids is not None:
411 return self._cached_uses_english_msgids
412
413@@ -181,6 +183,7 @@
414 @property
415 def singular_text(self):
416 """See `IPOTMsgSet`."""
417+ # TODO: convert to cachedproperty, it will be simpler.
418 if self._cached_singular_text is not None:
419 return self._cached_singular_text
420