Merge lp:~stub/launchpad/memcache into lp:launchpad

Proposed by Stuart Bishop
Status: Merged
Approved by: Stuart Bishop
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~stub/launchpad/memcache
Merge into: lp:launchpad
Diff against target: 723 lines (+606/-6)
8 files modified
lib/canonical/testing/layers.py (+5/-0)
lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt (+5/-0)
lib/lp/app/templates/root-index.pt (+10/-5)
lib/lp/services/memcache/configure.zcml (+6/-0)
lib/lp/services/memcache/doc/tales-cache.txt (+214/-0)
lib/lp/services/memcache/interfaces.py (+1/-1)
lib/lp/services/memcache/tales.py (+294/-0)
lib/lp/services/memcache/tests/test_doc.py (+71/-0)
To merge this branch: bzr merge lp:~stub/launchpad/memcache
Reviewer Review Type Date Requested Status
Gary Poster (community) Approve
Review via email: mp+20226@code.launchpad.net

Commit message

Add syntax to page templates to cache chunks of rendered content in memcached.

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

Implements the ability to cache rendered chunks of our page templates in memcached.

Readable, tested documentation included describing the syntax and functionality.

To install this new functionality, I had to resort to monkey patching. The solution to this will be to move this feature upstream into zope.tal and zope.tales. We are not worrying about this yet as we are considering switching our TAL interpreter to chameleon.

Revision history for this message
Gary Poster (gary) wrote :
Download full text (4.2 KiB)

merge-conditional

Hi Stuart. This is very cool! Thank you.

Might as well clean up the ``#level debug`` comments in launchpad.conf.

I question including the "anonymous" visibility. You bring up a good reason for not including it. Why don't we just exclude it this time around? It feels like something that might be interesting for upstream but not for right now--and like something that people will use unnecessarily. (On the other hand, if you want to push back, in the interest of saving you from waiting for me to wake up on the other side of the globe, you may merely imagine me acquiescing, and keep it, if you like.)

I liked your doctest.

Line 383 of the diff incorrectly describes the syntax, AIUI: <div tal:content="cache:1h public">. Please correct it.

I would be tempted to try to provide a more helpful error message when someone includes too many commas (re "self.visibility, max_age = (s.strip() for s in expr.split(','))" from line 392 of the diff. Line 403 ("value, unit = max_age.split(' ')") is similar. If you can't be bothered, I'll look the other way.

You say that "units is one of 'seconds', 'minutes', 'hours' or 'days'." However, you accept s*, m*, h* and d*. You are much stricter about the visibility strings. I'm inclined to favor enforcing readability, and enforce the full strings. This is negotiable (that is, under the circumstances, you may hear that as "ignorable" if you wish) but I feel more strongly about this one than some others I've brought up with similar deference.

You generate _valid_key_charactersbut then you never use it. Maybe delete it?

I am curious why you are not using SPACE (32) as your delineator, as opposed to the colon, which forces your logic to have to be a bit trickier here and there. Maybe I'll see why later...

I figure you know this is OK because of the DB, but I was very mildly surprised by the confidence of "uid = str(logged_in_user.id)". If you are sure it is safe, that's great.

The way you are handling repeats is very interesting. It's an interesting problem. I first thought that your approach of adding one to the counter_key in the request annotations would not work very well in the case of nested loops, because if something changed, then it would do a very odd cascade that might put an old sub-item from one top-level section into another top-level section entirely. However, if a collection changes significantly from one repeat to the next--an item is inserted somewhere, rather than appended, in particular--you are kind of hosed anyway. I guess I'm fine with what you have, though I'd like it if you added a warning in the doctest that cacheing things in a repeat is perhaps a less appealing prospect than some others.

OK, I asked why you are not using SPACE (32) as your delineator, and now I see those colons in "pt:%s:%s,%s:%s:%d,%d:%d,%s". I see commas too, though. Why can't we use spaces for everything? Contrariwise, if we are separating with colons and commas, why are you not excluding commas from your valid characters? Contrariwise to both of those, or perhaps perpendicularly to them, if the url is at the end, we know that any colon or comma after the ones used in our st...

Read more...

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

On Sat, Feb 27, 2010 at 5:47 AM, Gary Poster <email address hidden> wrote:

> Might as well clean up the ``#level debug`` comments in launchpad.conf.

Done.

> I question including the "anonymous" visibility.  You bring up a good reason for not including it.  Why don't we just exclude it this time around?  It feels like something that might be interesting for upstream but not for right now--and like something that people will use unnecessarily.  (On the other hand, if you want to push back, in the interest of saving you from waiting for me to wake up on the other side of the globe, you may merely imagine me acquiescing, and keep it, if you like.)

I'd like to keep it because it completes the visibility model and I'd rather not have to re implement it later if we push this upstream or if we have real use cases where we do need it. Also, my comments are just my guess - is our squid configured to cache pages with query strings? I don't really know.

> Line 383 of the diff incorrectly describes the syntax, AIUI: <div tal:content="cache:1h public">.  Please correct it.

Fixed.

> I would be tempted to try to provide a more helpful error message when someone includes too many commas (re "self.visibility, max_age = (s.strip() for s in expr.split(','))" from line 392 of the diff.  Line 403 ("value, unit = max_age.split(' ')") is similar.  If you can't be bothered, I'll look the other way.

Fixed.

> You say that "units is one of 'seconds', 'minutes', 'hours' or 'days'."  However, you accept s*, m*, h* and d*.  You are much stricter about the visibility strings.  I'm inclined to favor enforcing readability, and enforce the full strings.  This is negotiable (that is, under the circumstances, you may hear that as "ignorable" if you wish) but I feel more strongly about this one than some others I've brought up with similar deference.

Ok. I did that because it made it simpler to accept plural forms. Fixed.

> You generate _valid_key_charactersbut then you never use it.  Maybe delete it?

Yes - that was cruft from earlier work.

> I am curious why you are not using SPACE (32) as your delineator, as opposed to the colon, which forces your logic to have to be a bit trickier here and there.  Maybe I'll see why later...

Space is not a valid character in memcache keys, but colon is. I'm also using a mixture of colon and comma because I think I have seen memcache reporting tools using this to summarize memcache utilization, but it doesn't really matter provided it isn't a number or one of the magic tokens like 'p' or 'a'. These tools might be a figment of an overactive imagination.

> The way you are handling repeats is very interesting.  It's an interesting problem.  I first thought that your approach of adding one to the counter_key in the request annotations would not work very well in the case of nested loops, because if something changed, then it would do a very odd cascade that might put an old sub-item from one top-level section into another top-level section entirely.  However, if a collection changes significantly from one repeat to the next--an item is inserted somewhere, rather than appended, in par...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/testing/layers.py'
2--- lib/canonical/testing/layers.py 2010-02-12 19:34:42 +0000
3+++ lib/canonical/testing/layers.py 2010-03-06 08:12:37 +0000
4@@ -537,6 +537,11 @@
5 def getPidFile(cls):
6 return os.path.join(config.root, '.memcache.pid')
7
8+ @classmethod
9+ def purge(cls):
10+ "Purge everything from our memcached."
11+ MemcachedLayer.client.flush_all() # Only do this in tests!
12+
13
14 class LibrarianLayer(BaseLayer):
15 """Provides tests access to a Librarian instance.
16
17=== modified file 'lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt'
18--- lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt 2010-01-08 21:23:15 +0000
19+++ lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt 2010-03-06 08:12:37 +0000
20@@ -78,6 +78,9 @@
21 Administrators can add a project. Here Foo Bar adds apache as a featured
22 project:
23
24+ >>> from canonical.testing.layers import MemcachedLayer
25+ >>> MemcachedLayer.purge() # Featured projects list is cached.
26+
27 >>> admin_browser.getControl('Add project').value = 'apache'
28 >>> admin_browser.getControl('Update').click()
29 >>> admin_browser.url
30@@ -111,6 +114,8 @@
31
32 == Removing a project ==
33
34+ >>> MemcachedLayer.purge() # Featured projects list is cached.
35+
36 >>> admin_browser.getLink(MANAGE_LINK).click()
37 >>> admin_browser.getControl('Apache').click()
38 >>> admin_browser.getControl('Update').click()
39
40=== modified file 'lib/lp/app/templates/root-index.pt'
41--- lib/lp/app/templates/root-index.pt 2010-02-22 17:58:40 +0000
42+++ lib/lp/app/templates/root-index.pt 2010-03-06 08:12:37 +0000
43@@ -86,7 +86,7 @@
44 <div class="yui-g">
45 <div class="yui-u first">
46 <div class="homepage-whatslaunchpad"
47- tal:condition="not:view/user">
48+ tal:condition="not:view/user" tal:content="cache:anonymous">
49 <h2><span class="launchpad-gold">Launchpad</span> is a software collaboration platform that provides:</h2>
50 <ul tal:define="apphomes view/apphomes">
51 <li><a tal:attributes="href apphomes/bugs"><img src="/@@/bug" alt="" /></a>
52@@ -159,7 +159,7 @@
53 <input id="text" type="text" name="field.text" size="25%" />
54 <input id="search" type="submit" value="Search Launchpad" />
55 </form>
56- <div id="homepage-stats">
57+ <div id="homepage-stats" tal:content="cache:public, 1 hour">
58 <strong class="registry-stat"
59 tal:content="view/project_count/fmt:intcomma">123</strong>&nbsp;projects,
60 <strong class="bugs-stat"
61@@ -182,7 +182,8 @@
62 </tal:logged_out>You can test Launchpad's functionality
63 in our sandbox environment.
64 (<a href="/+help/home-page-staging-help.html" target="help">What's this?</a>)<br />
65- <tal:logged_in condition="view/user" omit-tag="">
66+ <tal:logged_in condition="view/user" omit-tag=""
67+ tal:content="cache:public">
68 If you're ready, you can:
69 <ul tal:define="apphomes view/apphomes">
70 <li><a href="https://help.launchpad.net/">
71@@ -207,7 +208,10 @@
72 </tal:logged_in>
73 </div>
74
75- <div id="homepage-featured" class="homepage-portlet">
76+ <div id="homepage-featured" class="homepage-portlet"
77+ tal:content="cache:anonymous, 1 hour">
78+ <tal:cache
79+ tal:content="cache:public, 5 minutes" tal:omit-tag="">
80 <h2>Featured projects</h2>
81
82 <div class="featured-project-top"
83@@ -231,9 +235,10 @@
84 </li>
85 </ul>
86 </div>
87+ </tal:cache>
88
89 <ul class="horizontal">
90- <li>
91+ <li tal:content="cache:public, 1 hour">
92 <strong><a href="/projects">Browse all
93 <tal:count content="view/project_count">42</tal:count>
94 projects</a>!</strong>
95
96=== modified file 'lib/lp/services/memcache/configure.zcml'
97--- lib/lp/services/memcache/configure.zcml 2009-09-16 12:47:23 +0000
98+++ lib/lp/services/memcache/configure.zcml 2010-03-06 08:12:37 +0000
99@@ -5,10 +5,16 @@
100 xmlns="http://namespaces.zope.org/zope"
101 xmlns:browser="http://namespaces.zope.org/browser"
102 xmlns:i18n="http://namespaces.zope.org/i18n"
103+ xmlns:tales="http://namespaces.zope.org/tales"
104 i18n_domain="launchpad">
105+
106+ <!-- Main memcache interface - the IMemcacheClient Utility -->
107 <utility
108 provides="lp.services.memcache.interfaces.IMemcacheClient"
109 factory="lp.services.memcache.client.memcache_client_factory"
110 />
111+
112+ <!-- TALES expression letting us cache chunks of rendered templates -->
113+ <tales:expressiontype name="cache" handler=".tales.MemcacheExpr" />
114 </configure>
115
116
117=== added directory 'lib/lp/services/memcache/doc'
118=== added file 'lib/lp/services/memcache/doc/tales-cache.txt'
119--- lib/lp/services/memcache/doc/tales-cache.txt 1970-01-01 00:00:00 +0000
120+++ lib/lp/services/memcache/doc/tales-cache.txt 2010-03-06 08:12:37 +0000
121@@ -0,0 +1,214 @@
122+Memcache with TALES
123+===================
124+
125+We have extended TALES with a cache: expression to allow chunks of
126+rendered page templates to be cached in Memcached.
127+
128+
129+ >>> template = TestPageTemplate(dedent("""\
130+ ... <div tal:content="cache:public">
131+ ... <span tal:content="param">placeholder</span>
132+ ... </div>"""))
133+
134+
135+The first time we render the page template, there is no information
136+in the cache. The cachable section is interpreted and stored in the cache
137+for next time.
138+
139+ >>> print template(param='first')
140+ <div>
141+ <span>first</span>
142+ </div>
143+
144+
145+The second time we render the page template, the cached information
146+is used. We prove this here by changing our parameters, which would
147+cause this template to render differently.
148+
149+ >>> print template(param='second')
150+ <div>
151+ <span>first</span>
152+ </div>
153+
154+
155+If we clear the cache, it will be rendered as expected.
156+
157+ >>> MemcachedLayer.purge()
158+ >>> print template(param='third')
159+ <div>
160+ <span>third</span>
161+ </div>
162+
163+
164+Expiry
165+------
166+
167+We can specify how long cached information is considered valid. If
168+this is not set, the information may be cached indefinitely. Note
169+that memcache may evict information sooner if it runs low on storage
170+space.
171+
172+One interesting technique is to specify a lengthy expiry, but to
173+refresh the information asynchronously using an AJAX request. This
174+is good enough for bots and improves the initial page load time, but
175+care will be needed to avoid 'popping'.
176+
177+ >>> template = TestPageTemplate(dedent("""\
178+ ... <body tal:omit-tag="">
179+ ... <div tal:content="cache:public,30 seconds" tal:omit-tag="">
180+ ... This bit cached up to 30 seconds.
181+ ... </div>
182+ ... <div tal:content="cache:public,1 minute" tal:omit-tag="">
183+ ... This bit cached up to 1 minute.
184+ ... </div>
185+ ... <div tal:content="cache:public,6 hours" tal:omit-tag="">
186+ ... This bit cached up to 6 hours.
187+ ... </div>
188+ ... <tal:cached content="cache:public,3 days">
189+ ... This bit cached up to 3 days.
190+ ... </tal:cached>
191+ ... </body>"""))
192+ >>> print template()
193+ This bit cached up to 30 seconds.
194+ This bit cached up to 1 minute.
195+ This bit cached up to 6 hours.
196+ This bit cached up to 3 days.
197+
198+
199+Visibility
200+----------
201+
202+We define 4 different types of 'visibility':
203+
204+ public
205+
206+ The cached information is shared by everyone. These sections
207+ should not be personalized. They can contain private information
208+ if the page itself is protected.
209+
210+ private
211+
212+ Unauthenticated users share cached information, but
213+ authenticated users do not share with anyone else. A list on a
214+ publicly accessible page that might contain private information
215+ should use this visibility.
216+
217+ anonymous
218+
219+ Unauthenticated users share cached information, but
220+ authenticated users do not use the cache at all. This can
221+ be used to feed bots cached information quicky, while giving
222+ authenticated users up to date information. In practice, this
223+ might not make much difference as reverse proxies should
224+ already be caching the entire page for unauthenticated users.
225+
226+ authenticated
227+
228+ Unauthenticated users share cached information, and all
229+ authenticated users share a different cache. This is used
230+ when information is being hidden from unauthenticated users,
231+ for example when we hide email addresses from unauthenticated
232+ users to help protect against email address harvesters.
233+
234+ >>> template = TestPageTemplate(dedent("""\
235+ ... <div tal:omit-tag="">
236+ ... <tal:cache content="cache:public">
237+ ... Public: <tal:x content="username" />
238+ ... </tal:cache>
239+ ... <tal:cache content="cache:private">
240+ ... Private: <tal:x content="username" />
241+ ... </tal:cache>
242+ ... <tal:cache content="cache:anonymous">
243+ ... Anonymous: <tal:x content="username" />
244+ ... </tal:cache>
245+ ... <tal:cache content="cache:authenticated">
246+ ... Authenticated: <tal:x content="username" />
247+ ... </tal:cache>
248+ ... </div>"""))
249+
250+Here we populate all caches.
251+
252+ >>> login(ANONYMOUS)
253+ >>> print template(username="Anonymous")
254+ Public: Anonymous
255+ Private: Anonymous
256+ Anonymous: Anonymous
257+ Authenticated: Anonymous
258+
259+Here we reuse the public cache, populate foo's private cache,
260+and populate the authenticated cache. The anonymous section is
261+uncached.
262+
263+ >>> login('foo.bar@canonical.com')
264+ >>> print template(username='Foo Bar')
265+ Public: Anonymous
266+ Private: Foo Bar
267+ Anonymous: Foo Bar
268+ Authenticated: Foo Bar
269+
270+Here we reuse the public cache, populate test's private cache, and
271+reuse the authenticated cache. The anonymous section is uncached.
272+
273+ >>> login('test@canonical.com')
274+ >>> print template(username='Test')
275+ Public: Anonymous
276+ Private: Test
277+ Anonymous: Test
278+ Authenticated: Foo Bar
279+
280+
281+Nesting & Loops
282+---------------
283+
284+Cached chunks can contain other cached chunks, useful for specifying
285+different timeouts of different visibilities.
286+
287+ >>> template = TestPageTemplate(dedent("""\
288+ ... <body tal:content="cache:private,25 seconds" tal:omit-tag="">
289+ ... This bit is private to <span tal:replace="username" />
290+ ... and cached up to 25 seconds, but contains
291+ ... <span tal:content="cache:public,3 days" tal:omit-tag="">
292+ ... this bit cached by <span tal:replace="username" />
293+ ... which is public and cached up to 3 days.
294+ ... </span>
295+ ... </body>"""))
296+
297+ >>> login('foo.bar@canonical.com')
298+ >>> print template(username="Foo Bar")
299+ This bit is private to Foo Bar and cached up to 25 seconds, but
300+ contains this bit cached by Foo Bar which is public and cached up
301+ to 3 days.
302+
303+ >>> login('test@canonical.com')
304+ >>> print template(username="Test")
305+ This bit is private to Test and cached up to 25 seconds, but
306+ contains this bit cached by Foo Bar which is public and cached up
307+ to 3 days.
308+
309+
310+tal:repeat loops are fully supported. Each iteration of the loop gets
311+a different cache.
312+
313+ >>> template = TestPageTemplate(dedent("""\
314+ ... <body>
315+ ... <div tal:repeat="i python:range(1,3)">
316+ ... <div tal:replace="cache:public">
317+ ... <span tal:replace="param" />
318+ ... <span tal:replace="repeat/i/index" />
319+ ... </div>
320+ ... </div>
321+ ... </body>"""))
322+ >>> print template(param='first')
323+ <body>
324+ <div> first 0 </div>
325+ <div> first 1 </div>
326+ </body>
327+
328+ >>> print template(param='second')
329+ <body>
330+ <div> first 0 </div>
331+ <div> first 1 </div>
332+ </body>
333+
334+
335+
336
337=== modified file 'lib/lp/services/memcache/interfaces.py'
338--- lib/lp/services/memcache/interfaces.py 2009-09-16 12:47:23 +0000
339+++ lib/lp/services/memcache/interfaces.py 2010-03-06 08:12:37 +0000
340@@ -4,7 +4,7 @@
341 """Memcached interfaces."""
342
343 __metaclass__ = type
344-__all__ = []
345+__all__ = ['IMemcacheClient']
346
347 from zope.interface import Interface
348
349
350=== added file 'lib/lp/services/memcache/tales.py'
351--- lib/lp/services/memcache/tales.py 1970-01-01 00:00:00 +0000
352+++ lib/lp/services/memcache/tales.py 2010-03-06 08:12:37 +0000
353@@ -0,0 +1,294 @@
354+# Copyright 2010 Canonical Ltd. This software is licensed under the
355+# GNU Affero General Public License version 3 (see the file LICENSE).
356+
357+"""Implementation of the cache: namespace in TALES."""
358+
359+__metaclass__ = type
360+__all__ = []
361+
362+
363+from hashlib import md5
364+import logging
365+import os.path
366+
367+from zope.component import getUtility
368+from zope.interface import implements
369+from zope.tal.talinterpreter import TALInterpreter, I18nMessageTypes
370+from zope.tales.interfaces import ITALESExpression
371+
372+from canonical.base import base
373+from canonical.config import config
374+from canonical.launchpad import versioninfo
375+from canonical.launchpad.webapp.interfaces import ILaunchBag
376+from lp.services.memcache.interfaces import IMemcacheClient
377+
378+
379+class MemcacheExpr:
380+ """Namespace to provide memcache caching of page template chunks.
381+
382+ This namespace is exclusively used in tal:content directives.
383+ The only sensible way of using this is the following syntax:
384+
385+ <div tal:content="cache:public, 1 hour">
386+ [... Potentially expensive page template chunk ...]
387+ </div>
388+ """
389+ implements(ITALESExpression)
390+ def __init__(self, name, expr, engine):
391+ """expr is in the format "visibility, 42 units".
392+
393+ visibility is one of...
394+
395+ public: All users see the same cached information.
396+
397+ private: Authenticated users see a personal copy of the cached
398+ information. Unauthenticated users share a copy of
399+ the cached information.
400+
401+ anonymous: Unauthenticated users use a shared copy of the
402+ cached information. Authenticated users don't
403+ use the cache. This probably isn't that useful
404+ in practice, as Anonymous requests should already
405+ be cached by reverse proxies on the production
406+ systems.
407+
408+ authenticated: Authenticated user share a copy of the cached
409+ information, and unauthenticated users share
410+ a seperate copy. Use this when information is
411+ being hidden from unauthenticated users, eg.
412+ for bug comments where email addresses are
413+ obfuscated for unauthenticated users.
414+
415+ units is one of 'seconds', 'minutes', 'hours' or 'days'.
416+
417+ visibility is required. If the cache timeout is not specified,
418+ it defaults to 'never timeout' (memcache will still purge the
419+ information when in a LRU fashion when things fill up).
420+ """
421+ self._s = expr
422+
423+ if ',' in expr:
424+ try:
425+ self.visibility, max_age = (s.strip() for s in expr.split(','))
426+ except ValueError:
427+ raise SyntaxError("Too many arguments in cache: expression")
428+ else:
429+ self.visibility = expr.strip()
430+ max_age = None
431+ assert self.visibility in (
432+ 'anonymous', 'public', 'private', 'authenticated',
433+ ), 'visibility must be anonymous, public, private or authenticated'
434+
435+ if max_age is None:
436+ self.max_age = 0
437+ else:
438+ try:
439+ value, unit = max_age.split(' ')
440+ except ValueError:
441+ raise SyntaxError(
442+ "Unparsable age %s in cache: expression"
443+ % repr(self.max_age))
444+ value = float(value)
445+ if unit[-1] == 's':
446+ unit = unit[:-1]
447+ if unit == 'second':
448+ pass
449+ elif unit == 'minute':
450+ value *= 60
451+ elif unit == 'hour':
452+ value *= 60 * 60
453+ elif unit == 'day':
454+ value *= 24 * 60 * 60
455+ else:
456+ raise AssertionError("Unknown unit %s" % unit)
457+ self.max_age = int(value)
458+
459+ # For use with str.translate to sanitize keys. No control characters
460+ # allowed, and we skip ':' too since it is a magic separator.
461+ _key_translate_map = (
462+ '_'*33 + ''.join(chr(i) for i in range(33, ord(':'))) + '_'
463+ + ''.join(chr(i) for i in range(ord(':')+1, 127)) + '_' * 129)
464+
465+ def getKey(self, econtext):
466+ """We need to calculate a unique key for this cached chunk.
467+
468+ To ensure content is uniquely identified, we must include:
469+ - a user id if this chunk is not 'public'
470+ - the template source file name
471+ - the position in the source file
472+ - a counter to cope with cached chunks in loops
473+ - the revision number of the source tree
474+ - the config in use
475+ - the URL and query string
476+ """
477+ # We include the URL and query string in the key.
478+ # We use the full, unadulterated url to calculate a hash.
479+ # We use a sanitized version in the human readable chunk of
480+ # the key.
481+ request = econtext.getValue('request')
482+ url = str(request.URL) + '?' + str(request.get('QUERY_STRING', ''))
483+ url = url.encode('utf8') # Ensure it is a byte string.
484+ sanitized_url = url.translate(self._key_translate_map)
485+
486+ # We include the source file and position in the source file in
487+ # the key.
488+ source_file = os.path.abspath(econtext.source_file)
489+ source_file = source_file[
490+ len(os.path.commonprefix([source_file, config.root + '/lib']))+1:]
491+
492+ # We include the visibility in the key so private information
493+ # is not leaked. We use 'p' for public information, 'a' for
494+ # unauthenticated user information, 'l' for information shared
495+ # between all authenticated users, or ${Person.id} for private
496+ # information.
497+ if self.visibility == 'public':
498+ uid = 'p'
499+ else:
500+ logged_in_user = getUtility(ILaunchBag).user
501+ if logged_in_user is None:
502+ uid = 'a'
503+ elif self.visibility == 'authenticated':
504+ uid = 'l'
505+ else: # private visibility
506+ uid = str(logged_in_user.id)
507+
508+ # We include a counter in the key, reset at the start of the
509+ # request, to ensure we get unique but repeatable keys inside
510+ # tal:repeat loops.
511+ counter_key = 'lp.services.memcache.tales.counter'
512+ counter = request.annotations.get(counter_key, 0) + 1
513+ request.annotations[counter_key] = counter
514+
515+ # We use pt: as a unique prefix to ensure no clashes with other
516+ # components using the memcached servers. The order of components
517+ # below only matters for human readability and memcached reporting
518+ # tools - it doesn't really matter provided all the components are
519+ # included and separators used.
520+ key = "pt:%s:%s,%s:%s:%d,%d:%d,%s" % (
521+ config.instance_name,
522+ source_file, versioninfo.revno, uid,
523+ econtext.position[0], econtext.position[1], counter,
524+ sanitized_url,
525+ )
526+
527+ # Memcached max key length is 250, so truncate but ensure uniqueness
528+ # with a hash. A short hash is good, provided it is still unique,
529+ # to preserve readability as much as possible. We include the
530+ # unsanitized URL in the hash to ensure uniqueness.
531+ key_hash = base(int(md5(key + url).hexdigest(), 16), 62)
532+ key = key[:250-len(key_hash)] + key_hash
533+
534+ return key
535+
536+ def __call__(self, econtext):
537+
538+ # If we have an 'anonymous' visibility chunk and are logged in,
539+ # we don't cache. Return the 'default' magic token to interpret
540+ # the contents.
541+ request = econtext.getValue('request')
542+ if (self.visibility == 'anonymous'
543+ and getUtility(ILaunchBag).user is not None):
544+ return econtext.getDefault()
545+
546+ # Calculate a unique key so we serve the right cached information.
547+ key = self.getKey(econtext)
548+
549+ cached_chunk = getUtility(IMemcacheClient).get(key)
550+
551+ if cached_chunk is None:
552+ logging.debug("Memcache miss for %s", key)
553+ return MemcacheMiss(key, self.max_age)
554+ else:
555+ logging.debug("Memcache hit for %s", key)
556+ return MemcacheHit(cached_chunk)
557+
558+ def __str__(self):
559+ return 'memcache expression (%s)' % self._s
560+
561+ def __repr__(self):
562+ return '<MemcacheExpr %s>' % self._s
563+
564+
565+class MemcacheMiss:
566+ """Callback for the customized TALInterpreter to invoke.
567+
568+ If the memcache hit failed, the TALInterpreter interprets the
569+ tag contents and invokes this callback, which will store the
570+ result in memcache against the key calculated by the MemcacheExpr.
571+ """
572+ def __init__(self, key, max_age):
573+ self._key = key
574+ self._max_age = max_age
575+
576+ def __call__(self, value):
577+ if getUtility(IMemcacheClient).set(
578+ self._key, value, self._max_age):
579+ logging.debug("Memcache set succeeded for %s", self._key)
580+ else:
581+ logging.warn("Memcache set failed for %s", self._key)
582+
583+ def __repr__(self):
584+ return "<MemcacheCallback %s %d>" % (self._key, self._max_age)
585+
586+
587+class MemcacheHit:
588+ """A prerendered chunk retrieved from cache.
589+
590+ We use a special object so the TALInterpreter knows that this
591+ information should not be quoted.
592+ """
593+ def __init__(self, value):
594+ self.value = value
595+
596+
597+# Oh my bleeding eyes! Monkey patching & cargo culting seems the sanest
598+# way of installing our extensions, which makes me sad.
599+
600+def do_insertText_tal(self, stuff):
601+ text = self.engine.evaluateText(stuff[0])
602+ if text is None:
603+ return
604+ if text is self.Default:
605+ self.interpret(stuff[1])
606+ return
607+ # Start Launchpad customization
608+ if isinstance(text, MemcacheMiss):
609+ # We got a MemcacheCallback instance. This means we hit a
610+ # content="cache:..." attribute but there was no valid
611+ # data in memcache. So we need to interpret the enclosed
612+ # chunk of template and stuff it in the cache for next time.
613+ callback = text
614+ self.pushStream(self.StringIO())
615+ self.interpret(stuff[1])
616+ text = self.stream.getvalue()
617+ self.popStream()
618+ # Now we have generated the chunk, cache it for next time.
619+ callback(text)
620+ # And output it to the currently rendered page, unquoted.
621+ self.stream_write(text)
622+ return
623+ if isinstance(text, MemcacheHit):
624+ # Got a hit. Include the contents directly into the
625+ # rendered page, unquoted.
626+ self.stream_write(text.value)
627+ return
628+ # End Launchpad customization
629+ if isinstance(text, I18nMessageTypes):
630+ # Translate this now.
631+ text = self.translate(text)
632+ self._writeText(text)
633+TALInterpreter.bytecode_handlers_tal["insertText"] = do_insertText_tal
634+
635+
636+# Just like the original, except MemcacheHit and MemcacheMiss
637+# instances are also passed through unharmed.
638+def evaluateText(self, expr):
639+ text = self.evaluate(expr)
640+ if (text is None
641+ or isinstance(text, (basestring, MemcacheHit, MemcacheMiss))
642+ or text is self.getDefault()):
643+ return text
644+ return unicode(text)
645+import zope.pagetemplate.engine
646+zope.pagetemplate.engine.ZopeContextBase.evaluateText = evaluateText
647+
648
649=== added file 'lib/lp/services/memcache/tests/test_doc.py'
650--- lib/lp/services/memcache/tests/test_doc.py 1970-01-01 00:00:00 +0000
651+++ lib/lp/services/memcache/tests/test_doc.py 2010-03-06 08:12:37 +0000
652@@ -0,0 +1,71 @@
653+# Copyright 2010 Canonical Ltd. This software is licensed under the
654+# GNU Affero General Public License version 3 (see the file LICENSE).
655+
656+"""Run doctests."""
657+
658+__metaclass__ = type
659+
660+import os.path
661+from textwrap import dedent
662+import unittest
663+
664+from zope.component import getUtility
665+import zope.pagetemplate.engine
666+from zope.pagetemplate.pagetemplate import PageTemplate
667+from zope.publisher.browser import TestRequest
668+
669+from canonical.launchpad.testing.systemdocs import (
670+ LayeredDocFileSuite, setUp, tearDown)
671+from canonical.testing.layers import LaunchpadFunctionalLayer, MemcachedLayer
672+from lp.services.memcache.interfaces import IMemcacheClient
673+from lp.services.testing import build_test_suite
674+from lp.testing import TestCase
675+
676+
677+here = os.path.dirname(os.path.realpath(__file__))
678+
679+
680+class TestPageTemplate(PageTemplate):
681+ """A cutdown PageTemplate implementation suitable for our tests."""
682+
683+ _num_instances = 0
684+
685+ def __init__(self, source):
686+ super(TestPageTemplate, self).__init__()
687+ TestPageTemplate._num_instances += 1
688+ self._my_instance_num = TestPageTemplate._num_instances
689+ self.pt_edit(source, 'text/html')
690+
691+ def pt_source_file(self):
692+ return 'fake/test_%d.pt' % self._my_instance_num
693+
694+ def pt_getEngine(self):
695+ # The <tales:expressiontype> ZCML only registers with this
696+ # engine, not the default.
697+ return zope.pagetemplate.engine.Engine
698+
699+ def pt_getContext(self, args=(), options={}):
700+ # Build a minimal context. The cache: expression requires
701+ # a request.
702+ context = {'request': TestRequest()}
703+ context.update(options)
704+ return context
705+
706+
707+def memcacheSetUp(test):
708+ setUp(test)
709+ test.globs['TestPageTemplate'] = TestPageTemplate
710+ test.globs['dedent'] = dedent
711+ test.globs['MemcachedLayer'] = MemcachedLayer
712+
713+
714+special = {
715+ 'tales-cache.txt': LayeredDocFileSuite(
716+ '../doc/tales-cache.txt',
717+ setUp=memcacheSetUp, tearDown=tearDown,
718+ layer=LaunchpadFunctionalLayer),
719+ }
720+
721+
722+def test_suite():
723+ return build_test_suite(here, special, layer=LaunchpadFunctionalLayer)