Merge lp:~lifeless/launchpad/registry into lp:launchpad
- registry
- Merge into devel
Status: | Merged | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Approved by: | Curtis Hovey | ||||||||||||||||
Approved revision: | no longer in the source branch. | ||||||||||||||||
Merged at revision: | 11367 | ||||||||||||||||
Proposed branch: | lp:~lifeless/launchpad/registry | ||||||||||||||||
Merge into: | lp:launchpad | ||||||||||||||||
Diff against target: |
835 lines (+396/-56) 12 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/interfaces/person.py (+19/-7) lib/lp/registry/model/distribution.py (+4/-3) lib/lp/registry/model/person.py (+160/-35) lib/lp/registry/model/product.py (+1/-2) lib/lp/registry/tests/test_person.py (+40/-2) lib/lp/soyuz/model/archive.py (+8/-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/registry | ||||||||||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Henning Eggers (community) | code | Approve | |
Curtis Hovey (community) | code | Approve | |
Review via email: mp+32067@code.launchpad.net |
Commit message
Reduce the query count for team/participants to 11 from hundreds for ubuntu-dev.
Description of the change
Make the /participation API make a constant number of DB calls, rather than scaling per-team-member found.
This change has a few key components.
First, the existing allmembers attribute is perserved unaltered; rather a new all_members_
A common helper, _all_members is created on Person, which can prepopulate various attributes.
Secondly, all the attributes that are exported as fields, which the API wants to examine, are prepopulated, bringing the query count down to 11 for a team with 3 members (from nearly 40 - and that 11 is now constant in tests).
The prepopulation is done via cachedproperty decorators, which should be totally fine for webservice and API requests but does introduce a risk that other scripts which acccess these properties, and read-them-
I haven't run a full test suite yet, I will be doing so to find out if there are any side effects which will need fixing.
All that said, I think this is a more robust approach than having a separate CachedPersonDec
William Grant (wgrant) wrote : | # |
I think all POSTs redirect before displaying the subsequent page, so that should be fine. However, I'm very suspicious of widely deploying @cachedproperty... I'd be much happier if it was populated only by the explicit prejoin, and operated uncached in the normal case.
Robert Collins (lifeless) wrote : | # |
On Tue, Aug 10, 2010 at 8:59 PM, William Grant <email address hidden> wrote:
> I think all POSTs redirect before displaying the subsequent page, so that should be fine. However, I'm very suspicious of widely deploying @cachedproperty... I'd be much happier if it was populated only by the explicit prejoin, and operated uncached in the normal case.
We /need/ a strategy for doing non-collection derived data in the
model, or we'll be playing 'omfg this is ugly shit', forever - and
never getting things fast.
Most of our pages only access 3 or 4 linked collections of objects,
but they generally do well over a hundred queries: this is
pathological!
I think its better that we experiment and find the problems and fix
them, than push back on actually getting what we need in place by
making the problem bigger than it is: cachedproperty isn't ideal, but
its a very small amount of work to use it.
-Rob
Robert Collins (lifeless) wrote : | # |
This needs an incremental review.
Please ignore the cachedproperty.py changes other than those in rev 11324 - they are in my separate, approved, lp:~lifeless/launchpad/cachedproperty branch - that I started after this review, but merged in because it makes the branch cleaner and leaner.
You could pull that branch and merge this into it to see the incremental changes.
I'm particularly interested in the removeSecurityProxy calls in cachedproperty.py: I think they are appropriate and needed, but perhaps not?
The change in 11323 to invalidate the cache on Person is definitely needed and an expected consequence of more model object caching; taste wise we probably want to iterate towards an interface where the cache description ('_archive_cached') matches the cache attribute ('.archive') - but I think that that is essentially polish, not a prerequisite.
Henning Eggers (henninge) wrote : | # |
Hi Robert,
thanks for all this work. We talked about the use of removeSecurityProxy in cachedproperty.py. It has to be clear that these are low-level functions that don't care about security. Security has to be considered as each call site. So this is mainly a documentation issue.
Please use "naked_instance" in the functions, though, to be clear about it.
Also, we agreed that you file a tech-debt bug about moving the code to lp.services and creating an extra doctest file instead of tests in docstring. Adding a unittest for extra points. ;-)
We will have to watch how these functions will be used in the future.
Cheers,
Henning
Preview Diff
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 02:31:51 +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 02:31:51 +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 02:31:51 +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 02:31:51 +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/interfaces/person.py' |
267 | --- lib/lp/registry/interfaces/person.py 2010-08-03 08:49:19 +0000 |
268 | +++ lib/lp/registry/interfaces/person.py 2010-08-17 02:31:51 +0000 |
269 | @@ -1231,7 +1231,7 @@ |
270 | all_member_count = Attribute( |
271 | "The total number of real people who are members of this team, " |
272 | "including subteams.") |
273 | - allmembers = exported( |
274 | + all_members_prepopulated = exported( |
275 | doNotSnapshot( |
276 | CollectionField( |
277 | title=_("All participants of this team."), |
278 | @@ -1243,6 +1243,8 @@ |
279 | "IPerson.inTeam()."), |
280 | value_type=Reference(schema=Interface))), |
281 | exported_as='participants') |
282 | + allmembers = doNotSnapshot( |
283 | + Attribute("List of all members, without checking karma etc.")) |
284 | approvedmembers = doNotSnapshot( |
285 | Attribute("List of members with APPROVED status")) |
286 | deactivated_member_count = Attribute("Number of deactivated members") |
287 | @@ -1322,14 +1324,17 @@ |
288 | center_lat, and center_lng |
289 | """ |
290 | |
291 | - def getMembersWithPreferredEmails(include_teams=False): |
292 | + def getMembersWithPreferredEmails(): |
293 | """Returns a result set of persons with precached addresses. |
294 | |
295 | Persons or teams without preferred email addresses are not included. |
296 | """ |
297 | |
298 | - def getMembersWithPreferredEmailsCount(include_teams=False): |
299 | - """Returns the count of persons/teams with preferred emails.""" |
300 | + def getMembersWithPreferredEmailsCount(): |
301 | + """Returns the count of persons/teams with preferred emails. |
302 | + |
303 | + See also getMembersWithPreferredEmails. |
304 | + """ |
305 | |
306 | def getDirectAdministrators(): |
307 | """Return this team's administrators. |
308 | @@ -2123,9 +2128,16 @@ |
309 | |
310 | |
311 | # Fix value_type.schema of IPersonViewRestricted attributes. |
312 | -for name in ['allmembers', 'activemembers', 'adminmembers', 'proposedmembers', |
313 | - 'invited_members', 'deactivatedmembers', 'expiredmembers', |
314 | - 'unmapped_participants']: |
315 | +for name in [ |
316 | + 'all_members_prepopulated', |
317 | + 'activemembers', |
318 | + 'adminmembers', |
319 | + 'proposedmembers', |
320 | + 'invited_members', |
321 | + 'deactivatedmembers', |
322 | + 'expiredmembers', |
323 | + 'unmapped_participants', |
324 | + ]: |
325 | IPersonViewRestricted[name].value_type.schema = IPerson |
326 | |
327 | IPersonPublic['sub_teams'].value_type.schema = ITeam |
328 | |
329 | === modified file 'lib/lp/registry/model/distribution.py' |
330 | --- lib/lp/registry/model/distribution.py 2010-08-11 02:17:14 +0000 |
331 | +++ lib/lp/registry/model/distribution.py 2010-08-17 02:31:51 +0000 |
332 | @@ -19,7 +19,7 @@ |
333 | from zope.interface import alsoProvides, implements |
334 | |
335 | from lp.archivepublisher.debversion import Version |
336 | -from canonical.cachedproperty import cachedproperty |
337 | +from canonical.cachedproperty import cachedproperty, clear_property |
338 | from canonical.database.constants import UTC_NOW |
339 | |
340 | from canonical.database.datetimecol import UtcDateTimeCol |
341 | @@ -1617,8 +1617,9 @@ |
342 | # This driver is a release manager. |
343 | series.driver = owner |
344 | |
345 | - if safe_hasattr(self, '_cached_series'): |
346 | - del self._cached_series |
347 | + # May wish to add this to the series rather than clearing the cache -- |
348 | + # RBC 20100816. |
349 | + clear_property(self, '_cached_series') |
350 | return series |
351 | |
352 | @property |
353 | |
354 | === modified file 'lib/lp/registry/model/person.py' |
355 | --- lib/lp/registry/model/person.py 2010-08-13 08:26:15 +0000 |
356 | +++ lib/lp/registry/model/person.py 2010-08-17 02:31:51 +0000 |
357 | @@ -48,7 +48,9 @@ |
358 | StringCol) |
359 | from sqlobject.sqlbuilder import AND, OR, SQLConstant |
360 | from storm.store import EmptyResultSet, Store |
361 | -from storm.expr import And, In, Join, LeftJoin, Lower, Not, Or, SQL |
362 | +from storm.expr import ( |
363 | + Alias, And, Exists, In, Join, LeftJoin, Lower, Min, Not, Or, Select, SQL, |
364 | + ) |
365 | from storm.info import ClassAlias |
366 | |
367 | from canonical.config import config |
368 | @@ -59,10 +61,11 @@ |
369 | from canonical.database.sqlbase import ( |
370 | cursor, quote, quote_like, sqlvalues, SQLBase) |
371 | |
372 | -from canonical.cachedproperty import cachedproperty |
373 | +from canonical.cachedproperty import cachedproperty, cache_property, clear_property |
374 | |
375 | from canonical.lazr.utils import get_current_browser_request, safe_hasattr |
376 | |
377 | +from canonical.launchpad.components.decoratedresultset import DecoratedResultSet |
378 | from canonical.launchpad.database.account import Account, AccountPassword |
379 | from canonical.launchpad.interfaces.account import AccountSuspendedError |
380 | from lp.bugs.model.bugtarget import HasBugsBase |
381 | @@ -390,13 +393,12 @@ |
382 | |
383 | Order them by name if necessary. |
384 | """ |
385 | - self._languages_cache = sorted( |
386 | - languages, key=attrgetter('englishname')) |
387 | + cache_property(self, '_languages_cache', sorted( |
388 | + languages, key=attrgetter('englishname'))) |
389 | |
390 | def deleteLanguagesCache(self): |
391 | """Delete this person's cached languages, if it exists.""" |
392 | - if safe_hasattr(self, '_languages_cache'): |
393 | - del self._languages_cache |
394 | + clear_property(self, '_languages_cache') |
395 | |
396 | def addLanguage(self, language): |
397 | """See `IPerson`.""" |
398 | @@ -1012,9 +1014,10 @@ |
399 | result = result.order_by(KarmaCategory.title) |
400 | return [karma_cache for (karma_cache, category) in result] |
401 | |
402 | - @property |
403 | + @cachedproperty('_karma_cached') |
404 | def karma(self): |
405 | """See `IPerson`.""" |
406 | + # May also be loaded from _all_members |
407 | cache = KarmaTotalCache.selectOneBy(person=self) |
408 | if cache is None: |
409 | # Newly created accounts may not be in the cache yet, meaning the |
410 | @@ -1032,7 +1035,7 @@ |
411 | |
412 | return self.is_valid_person |
413 | |
414 | - @property |
415 | + @cachedproperty('_is_valid_person_cached') |
416 | def is_valid_person(self): |
417 | """See `IPerson`.""" |
418 | if self.is_team: |
419 | @@ -1431,14 +1434,131 @@ |
420 | @property |
421 | def allmembers(self): |
422 | """See `IPerson`.""" |
423 | - query = """ |
424 | - Person.id = TeamParticipation.person AND |
425 | - TeamParticipation.team = %s AND |
426 | - TeamParticipation.person != %s |
427 | - """ % sqlvalues(self.id, self.id) |
428 | - return Person.select(query, clauseTables=['TeamParticipation']) |
429 | - |
430 | - def _getMembersWithPreferredEmails(self, include_teams=False): |
431 | + return self._all_members() |
432 | + |
433 | + @property |
434 | + def all_members_prepopulated(self): |
435 | + """See `IPerson`.""" |
436 | + return self._all_members(need_karma=True, need_ubuntu_coc=True, |
437 | + need_location=True, need_archive=True, need_preferred_email=True, |
438 | + need_validity=True) |
439 | + |
440 | + def _all_members(self, need_karma=False, need_ubuntu_coc=False, |
441 | + need_location=False, need_archive=False, need_preferred_email=False, |
442 | + need_validity=False): |
443 | + """Lookup all members of the team with optional precaching. |
444 | + |
445 | + :param need_karma: The karma attribute will be cached. |
446 | + :param need_ubuntu_coc: The is_ubuntu_coc_signer attribute will be |
447 | + cached. |
448 | + :param need_location: The location attribute will be cached. |
449 | + :param need_archive: The archive attribute will be cached. |
450 | + :param need_preferred_email: The preferred email attribute will be |
451 | + cached. |
452 | + :param need_validity: The is_valid attribute will be cached. |
453 | + """ |
454 | + # TODO: consolidate this with getMembersWithPreferredEmails. |
455 | + # The difference between the two is that |
456 | + # getMembersWithPreferredEmails includes self, which is arguably |
457 | + # wrong, but perhaps deliberate. |
458 | + store = Store.of(self) |
459 | + origin = [ |
460 | + Person, |
461 | + Join(TeamParticipation, TeamParticipation.person == Person.id), |
462 | + ] |
463 | + conditions = And( |
464 | + # Members of this team, |
465 | + TeamParticipation.team == self.id, |
466 | + # But not the team itself. |
467 | + TeamParticipation.person != self.id) |
468 | + columns = [Person] |
469 | + if need_karma: |
470 | + # New people have no karmatotalcache rows. |
471 | + origin.append( |
472 | + LeftJoin(KarmaTotalCache, KarmaTotalCache.person == Person.id)) |
473 | + columns.append(KarmaTotalCache) |
474 | + if need_ubuntu_coc: |
475 | + columns.append(Alias(Exists(Select(SignedCodeOfConduct, |
476 | + AND(Person._is_ubuntu_coc_signer_condition(), |
477 | + SignedCodeOfConduct.ownerID == Person.id))), |
478 | + name='is_ubuntu_coc_signer')) |
479 | + if need_location: |
480 | + # New people have no location rows |
481 | + origin.append( |
482 | + LeftJoin(PersonLocation, PersonLocation.person == Person.id)) |
483 | + columns.append(PersonLocation) |
484 | + if need_archive: |
485 | + # Not everyone has PPA's |
486 | + # It would be nice to cleanly expose the soyuz rules for this to avoid |
487 | + # duplicating the relationships. |
488 | + origin.append( |
489 | + LeftJoin(Archive, Archive.owner == Person.id)) |
490 | + columns.append(Archive) |
491 | + conditions = And(conditions, |
492 | + Or(Archive.id == None, And( |
493 | + Archive.id == Select(Min(Archive.id), |
494 | + Archive.owner == Person.id, Archive), |
495 | + Archive.purpose == ArchivePurpose.PPA))) |
496 | + if need_preferred_email: |
497 | + # Teams don't have email. |
498 | + origin.append( |
499 | + LeftJoin(EmailAddress, EmailAddress.person == Person.id)) |
500 | + columns.append(EmailAddress) |
501 | + conditions = And(conditions, |
502 | + Or(EmailAddress.status == None, |
503 | + EmailAddress.status == EmailAddressStatus.PREFERRED)) |
504 | + if need_validity: |
505 | + # May find invalid persons |
506 | + origin.append( |
507 | + LeftJoin(ValidPersonCache, ValidPersonCache.id == Person.id)) |
508 | + columns.append(ValidPersonCache) |
509 | + if len(columns) == 1: |
510 | + columns = columns[0] |
511 | + # Return a simple ResultSet |
512 | + return store.using(*origin).find(columns, conditions) |
513 | + # Adapt the result into a cached Person. |
514 | + columns = tuple(columns) |
515 | + raw_result = store.using(*origin).find(columns, conditions) |
516 | + def prepopulate_person(row): |
517 | + result = row[0] |
518 | + index = 1 |
519 | + #-- karma caching |
520 | + if need_karma: |
521 | + karma = row[index] |
522 | + index += 1 |
523 | + if karma is None: |
524 | + karma_total = 0 |
525 | + else: |
526 | + karma_total = karma.karma_total |
527 | + cache_property(result, '_karma_cached', karma_total) |
528 | + #-- ubuntu code of conduct signer status caching. |
529 | + if need_ubuntu_coc: |
530 | + signed = row[index] |
531 | + index += 1 |
532 | + cache_property(result, '_is_ubuntu_coc_signer_cached', signed) |
533 | + #-- location caching |
534 | + if need_location: |
535 | + location = row[index] |
536 | + index += 1 |
537 | + cache_property(result, '_location', location) |
538 | + #-- archive caching |
539 | + if need_archive: |
540 | + archive = row[index] |
541 | + index += 1 |
542 | + cache_property(result, '_archive_cached', archive) |
543 | + #-- preferred email caching |
544 | + if need_preferred_email: |
545 | + email = row[index] |
546 | + index += 1 |
547 | + cache_property(result, '_preferredemail_cached', email) |
548 | + if need_validity: |
549 | + valid = row[index] is not None |
550 | + index += 1 |
551 | + cache_property(result, '_is_valid_person_cached', valid) |
552 | + return result |
553 | + return DecoratedResultSet(raw_result, result_decorator=prepopulate_person) |
554 | + |
555 | + def _getMembersWithPreferredEmails(self): |
556 | """Helper method for public getMembersWithPreferredEmails. |
557 | |
558 | We can't return the preferred email address directly to the |
559 | @@ -1456,20 +1576,18 @@ |
560 | EmailAddress.status == EmailAddressStatus.PREFERRED) |
561 | return store.using(*origin).find((Person, EmailAddress), conditions) |
562 | |
563 | - def getMembersWithPreferredEmails(self, include_teams=False): |
564 | + def getMembersWithPreferredEmails(self): |
565 | """See `IPerson`.""" |
566 | - result = self._getMembersWithPreferredEmails( |
567 | - include_teams=include_teams) |
568 | + result = self._getMembersWithPreferredEmails() |
569 | person_list = [] |
570 | for person, email in result: |
571 | - person._preferredemail_cached = email |
572 | + cache_property(person, '_preferredemail_cached', email) |
573 | person_list.append(person) |
574 | return person_list |
575 | |
576 | - def getMembersWithPreferredEmailsCount(self, include_teams=False): |
577 | + def getMembersWithPreferredEmailsCount(self): |
578 | """See `IPerson`.""" |
579 | - result = self._getMembersWithPreferredEmails( |
580 | - include_teams=include_teams) |
581 | + result = self._getMembersWithPreferredEmails() |
582 | return result.count() |
583 | |
584 | @property |
585 | @@ -1737,7 +1855,7 @@ |
586 | self.account_status = AccountStatus.DEACTIVATED |
587 | self.account_status_comment = comment |
588 | IMasterObject(self.preferredemail).status = EmailAddressStatus.NEW |
589 | - self._preferredemail_cached = None |
590 | + clear_property(self, '_preferredemail_cached') |
591 | base_new_name = self.name + '-deactivatedaccount' |
592 | self.name = self._ensureNewName(base_new_name) |
593 | |
594 | @@ -2070,7 +2188,7 @@ |
595 | if email_address is not None: |
596 | email_address.status = EmailAddressStatus.VALIDATED |
597 | email_address.syncUpdate() |
598 | - self._preferredemail_cached = None |
599 | + clear_property(self, '_preferredemail_cached') |
600 | |
601 | def setPreferredEmail(self, email): |
602 | """See `IPerson`.""" |
603 | @@ -2107,7 +2225,7 @@ |
604 | IMasterObject(email).syncUpdate() |
605 | |
606 | # Now we update our cache of the preferredemail. |
607 | - self._preferredemail_cached = email |
608 | + cache_property(self, '_preferredemail_cached', email) |
609 | |
610 | @cachedproperty('_preferredemail_cached') |
611 | def preferredemail(self): |
612 | @@ -2274,17 +2392,23 @@ |
613 | distribution.main_archive, self) |
614 | return permissions.count() > 0 |
615 | |
616 | - @cachedproperty |
617 | + @cachedproperty('_is_ubuntu_coc_signer_cached') |
618 | def is_ubuntu_coc_signer(self): |
619 | """See `IPerson`.""" |
620 | + # Also assigned to by self._all_members. |
621 | + store = Store.of(self) |
622 | + query = AND(SignedCodeOfConduct.ownerID == self.id, |
623 | + Person._is_ubuntu_coc_signer_condition()) |
624 | + # TODO: Using exists would be faster than count(). |
625 | + return bool(store.find(SignedCodeOfConduct, query).count()) |
626 | + |
627 | + @staticmethod |
628 | + def _is_ubuntu_coc_signer_condition(): |
629 | + """Generate a Storm Expr for determing the coc signing status.""" |
630 | sigset = getUtility(ISignedCodeOfConductSet) |
631 | lastdate = sigset.getLastAcceptedDate() |
632 | - |
633 | - query = AND(SignedCodeOfConduct.q.active==True, |
634 | - SignedCodeOfConduct.q.ownerID==self.id, |
635 | - SignedCodeOfConduct.q.datecreated>=lastdate) |
636 | - |
637 | - return bool(SignedCodeOfConduct.select(query).count()) |
638 | + return AND(SignedCodeOfConduct.active == True, |
639 | + SignedCodeOfConduct.datecreated >= lastdate) |
640 | |
641 | @property |
642 | def activesignatures(self): |
643 | @@ -2298,7 +2422,7 @@ |
644 | sCoC_util = getUtility(ISignedCodeOfConductSet) |
645 | return sCoC_util.searchByUser(self.id, active=False) |
646 | |
647 | - @property |
648 | + @cachedproperty('_archive_cached') |
649 | def archive(self): |
650 | """See `IPerson`.""" |
651 | return getUtility(IArchiveSet).getPPAOwnedByPerson(self) |
652 | @@ -2622,7 +2746,8 @@ |
653 | # Populate the previously empty 'preferredemail' cached |
654 | # property, so the Person record is up-to-date. |
655 | if master_email.status == EmailAddressStatus.PREFERRED: |
656 | - account_person._preferredemail_cached = master_email |
657 | + cache_property(account_person, '_preferredemail_cached', |
658 | + master_email) |
659 | return account_person |
660 | # There is no associated `Person` to the email `Account`. |
661 | # This is probably because the account was created externally |
662 | |
663 | === modified file 'lib/lp/registry/model/product.py' |
664 | --- lib/lp/registry/model/product.py 2010-08-06 14:49:35 +0000 |
665 | +++ lib/lp/registry/model/product.py 2010-08-17 02:31:51 +0000 |
666 | @@ -509,9 +509,8 @@ |
667 | |
668 | def __storm_invalidated__(self): |
669 | """Clear cached non-storm attributes when the transaction ends.""" |
670 | + super(Product, self).__storm_invalidated__() |
671 | self._cached_licenses = None |
672 | - if safe_hasattr(self, '_commercial_subscription_cached'): |
673 | - del self._commercial_subscription_cached |
674 | |
675 | def _getLicenses(self): |
676 | """Get the licenses as a tuple.""" |
677 | |
678 | === modified file 'lib/lp/registry/tests/test_person.py' |
679 | --- lib/lp/registry/tests/test_person.py 2010-08-13 07:48:54 +0000 |
680 | +++ lib/lp/registry/tests/test_person.py 2010-08-17 02:31:51 +0000 |
681 | @@ -1,12 +1,16 @@ |
682 | # Copyright 2009-2010 Canonical Ltd. This software is licensed under the |
683 | # GNU Affero General Public License version 3 (see the file LICENSE). |
684 | |
685 | +from __future__ import with_statement |
686 | + |
687 | __metaclass__ = type |
688 | |
689 | from datetime import datetime |
690 | import pytz |
691 | import time |
692 | |
693 | +from testtools.matchers import LessThan |
694 | + |
695 | import transaction |
696 | |
697 | from zope.component import getUtility |
698 | @@ -26,6 +30,7 @@ |
699 | IPersonSet, ImmutableVisibilityError, NameAlreadyTaken, |
700 | PersonCreationRationale, PersonVisibility) |
701 | from canonical.launchpad.database import Bug |
702 | +from canonical.launchpad.testing.pages import LaunchpadWebServiceCaller |
703 | from lp.registry.model.structuralsubscription import ( |
704 | StructuralSubscription) |
705 | from lp.registry.model.karma import KarmaCategory |
706 | @@ -34,8 +39,12 @@ |
707 | from lp.bugs.interfaces.bugtask import IllegalRelatedBugTasksParams |
708 | from lp.answers.model.answercontact import AnswerContact |
709 | from lp.blueprints.model.specification import Specification |
710 | -from lp.testing import login_person, logout, TestCase, TestCaseWithFactory |
711 | +from lp.testing import ( |
712 | + login_person, logout, person_logged_in, TestCase, TestCaseWithFactory, |
713 | + ) |
714 | +from lp.testing.matchers import HasQueryCount |
715 | from lp.testing.views import create_initialized_view |
716 | +from lp.testing._webservice import QueryCollector |
717 | from lp.registry.interfaces.person import PrivatePersonLinkageError |
718 | from canonical.testing.layers import DatabaseFunctionalLayer, reconnect_stores |
719 | |
720 | @@ -191,7 +200,8 @@ |
721 | |
722 | def test_person_snapshot(self): |
723 | omitted = ( |
724 | - 'activemembers', 'adminmembers', 'allmembers', 'approvedmembers', |
725 | + 'activemembers', 'adminmembers', 'allmembers', |
726 | + 'all_members_prepopulated', 'approvedmembers', |
727 | 'deactivatedmembers', 'expiredmembers', 'inactivemembers', |
728 | 'invited_members', 'member_memberships', 'pendingmembers', |
729 | 'proposedmembers', 'unmapped_participants', |
730 | @@ -553,3 +563,31 @@ |
731 | names = [entry['project'].name for entry in results] |
732 | self.assertEqual( |
733 | ['cc', 'bb', 'aa', 'dd', 'ee'], names) |
734 | + |
735 | + |
736 | +class TestAPIPartipication(TestCaseWithFactory): |
737 | + |
738 | + layer = DatabaseFunctionalLayer |
739 | + |
740 | + def test_participation_query_limit(self): |
741 | + # A team with 3 members should only query once for all their |
742 | + # attributes. |
743 | + team = self.factory.makeTeam() |
744 | + with person_logged_in(team.teamowner): |
745 | + team.addMember(self.factory.makePerson(), team.teamowner) |
746 | + team.addMember(self.factory.makePerson(), team.teamowner) |
747 | + team.addMember(self.factory.makePerson(), team.teamowner) |
748 | + webservice = LaunchpadWebServiceCaller() |
749 | + collector = QueryCollector() |
750 | + collector.register() |
751 | + self.addCleanup(collector.unregister) |
752 | + url = "/~%s/participants" % team.name |
753 | + logout() |
754 | + response = webservice.get(url, headers={'User-Agent':'AnonNeedsThis'}) |
755 | + self.assertEqual(response.status, 200, |
756 | + "Got %d for url %r with response %r" % ( |
757 | + response.status, url, response.body)) |
758 | + # XXX: This number should really be 10, but see |
759 | + # https://bugs.launchpad.net/storm/+bug/619017 which is adding 3 |
760 | + # queries to the test. |
761 | + self.assertThat(collector, HasQueryCount(LessThan(13))) |
762 | |
763 | === modified file 'lib/lp/soyuz/model/archive.py' |
764 | --- lib/lp/soyuz/model/archive.py 2010-08-05 13:23:52 +0000 |
765 | +++ lib/lp/soyuz/model/archive.py 2010-08-17 02:31:51 +0000 |
766 | @@ -25,6 +25,7 @@ |
767 | from lp.app.errors import NotFoundError |
768 | from lp.archivepublisher.debversion import Version |
769 | from lp.archiveuploader.utils import re_issource, re_isadeb |
770 | +from canonical.cachedproperty import clear_property |
771 | from canonical.config import config |
772 | from canonical.database.constants import UTC_NOW |
773 | from canonical.database.datetimecol import UtcDateTimeCol |
774 | @@ -1753,8 +1754,12 @@ |
775 | |
776 | # Signing-key for the default PPA is reused when it's already present. |
777 | signing_key = None |
778 | - if purpose == ArchivePurpose.PPA and owner.archive is not None: |
779 | - signing_key = owner.archive.signing_key |
780 | + if purpose == ArchivePurpose.PPA: |
781 | + if owner.archive is not None: |
782 | + signing_key = owner.archive.signing_key |
783 | + else: |
784 | + # owner.archive is a cached property and we've just cached it. |
785 | + clear_property(owner, '_archive_cached') |
786 | |
787 | new_archive = Archive( |
788 | owner=owner, distribution=distribution, name=name, |
789 | @@ -1806,6 +1811,7 @@ |
790 | |
791 | def getPPAOwnedByPerson(self, person, name=None): |
792 | """See `IArchiveSet`.""" |
793 | + # See Person._all_members which also directly queries this. |
794 | store = Store.of(person) |
795 | clause = [ |
796 | Archive.purpose == ArchivePurpose.PPA, |
797 | |
798 | === modified file 'lib/lp/translations/model/potemplate.py' |
799 | --- lib/lp/translations/model/potemplate.py 2010-08-06 09:32:02 +0000 |
800 | +++ lib/lp/translations/model/potemplate.py 2010-08-17 02:31:51 +0000 |
801 | @@ -185,6 +185,7 @@ |
802 | _uses_english_msgids = None |
803 | |
804 | def __storm_invalidated__(self): |
805 | + super(POTemplate, self).__storm_invalidated__() |
806 | self.clearPOFileCache() |
807 | self._uses_english_msgids = None |
808 | |
809 | |
810 | === modified file 'lib/lp/translations/model/potmsgset.py' |
811 | --- lib/lp/translations/model/potmsgset.py 2010-08-06 06:51:37 +0000 |
812 | +++ lib/lp/translations/model/potmsgset.py 2010-08-17 02:31:51 +0000 |
813 | @@ -103,6 +103,7 @@ |
814 | credits_message_ids = credits_message_info.keys() |
815 | |
816 | def __storm_invalidated__(self): |
817 | + super(POTMsgSet, self).__storm_invalidated__() |
818 | self._cached_singular_text = None |
819 | self._cached_uses_english_msgids = None |
820 | |
821 | @@ -157,6 +158,7 @@ |
822 | @property |
823 | def uses_english_msgids(self): |
824 | """See `IPOTMsgSet`.""" |
825 | + # TODO: convert to cachedproperty, it will be simpler. |
826 | if self._cached_uses_english_msgids is not None: |
827 | return self._cached_uses_english_msgids |
828 | |
829 | @@ -181,6 +183,7 @@ |
830 | @property |
831 | def singular_text(self): |
832 | """See `IPOTMsgSet`.""" |
833 | + # TODO: convert to cachedproperty, it will be simpler. |
834 | if self._cached_singular_text is not None: |
835 | return self._cached_singular_text |
836 |
I am very pleased to see this enhancement. I expressed concerns that the @cachedproperty on is_valid_person and archive may cause issues during updated and subsequent displays of the person. eg, A user chooses to deactivate his profile, then the page loads and says he is still active, but reloading the page shows the page is deactivated. I am speculating though.
I think this is good to land. We will be prepared to address @cachedproperties as we do with Person. preferredemail, where the mutator function delete the _*_cached property to clear the state.