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
=== modified file 'lib/canonical/testing/layers.py'
--- lib/canonical/testing/layers.py 2010-02-12 19:34:42 +0000
+++ lib/canonical/testing/layers.py 2010-03-06 08:12:37 +0000
@@ -537,6 +537,11 @@
537 def getPidFile(cls):537 def getPidFile(cls):
538 return os.path.join(config.root, '.memcache.pid')538 return os.path.join(config.root, '.memcache.pid')
539539
540 @classmethod
541 def purge(cls):
542 "Purge everything from our memcached."
543 MemcachedLayer.client.flush_all() # Only do this in tests!
544
540545
541class LibrarianLayer(BaseLayer):546class LibrarianLayer(BaseLayer):
542 """Provides tests access to a Librarian instance.547 """Provides tests access to a Librarian instance.
543548
=== modified file 'lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt'
--- lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt 2010-01-08 21:23:15 +0000
+++ lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt 2010-03-06 08:12:37 +0000
@@ -78,6 +78,9 @@
78Administrators can add a project. Here Foo Bar adds apache as a featured78Administrators can add a project. Here Foo Bar adds apache as a featured
79project:79project:
8080
81 >>> from canonical.testing.layers import MemcachedLayer
82 >>> MemcachedLayer.purge() # Featured projects list is cached.
83
81 >>> admin_browser.getControl('Add project').value = 'apache'84 >>> admin_browser.getControl('Add project').value = 'apache'
82 >>> admin_browser.getControl('Update').click()85 >>> admin_browser.getControl('Update').click()
83 >>> admin_browser.url86 >>> admin_browser.url
@@ -111,6 +114,8 @@
111114
112== Removing a project ==115== Removing a project ==
113116
117 >>> MemcachedLayer.purge() # Featured projects list is cached.
118
114 >>> admin_browser.getLink(MANAGE_LINK).click()119 >>> admin_browser.getLink(MANAGE_LINK).click()
115 >>> admin_browser.getControl('Apache').click()120 >>> admin_browser.getControl('Apache').click()
116 >>> admin_browser.getControl('Update').click()121 >>> admin_browser.getControl('Update').click()
117122
=== modified file 'lib/lp/app/templates/root-index.pt'
--- lib/lp/app/templates/root-index.pt 2010-02-22 17:58:40 +0000
+++ lib/lp/app/templates/root-index.pt 2010-03-06 08:12:37 +0000
@@ -86,7 +86,7 @@
86 <div class="yui-g">86 <div class="yui-g">
87 <div class="yui-u first">87 <div class="yui-u first">
88 <div class="homepage-whatslaunchpad"88 <div class="homepage-whatslaunchpad"
89 tal:condition="not:view/user">89 tal:condition="not:view/user" tal:content="cache:anonymous">
90 <h2><span class="launchpad-gold">Launchpad</span> is a software collaboration platform that provides:</h2>90 <h2><span class="launchpad-gold">Launchpad</span> is a software collaboration platform that provides:</h2>
91 <ul tal:define="apphomes view/apphomes">91 <ul tal:define="apphomes view/apphomes">
92 <li><a tal:attributes="href apphomes/bugs"><img src="/@@/bug" alt="" /></a>92 <li><a tal:attributes="href apphomes/bugs"><img src="/@@/bug" alt="" /></a>
@@ -159,7 +159,7 @@
159 <input id="text" type="text" name="field.text" size="25%" />159 <input id="text" type="text" name="field.text" size="25%" />
160 <input id="search" type="submit" value="Search Launchpad" />160 <input id="search" type="submit" value="Search Launchpad" />
161 </form>161 </form>
162 <div id="homepage-stats">162 <div id="homepage-stats" tal:content="cache:public, 1 hour">
163 <strong class="registry-stat"163 <strong class="registry-stat"
164 tal:content="view/project_count/fmt:intcomma">123</strong>&nbsp;projects,164 tal:content="view/project_count/fmt:intcomma">123</strong>&nbsp;projects,
165 <strong class="bugs-stat"165 <strong class="bugs-stat"
@@ -182,7 +182,8 @@
182 </tal:logged_out>You can test Launchpad's functionality182 </tal:logged_out>You can test Launchpad's functionality
183 in our sandbox environment.183 in our sandbox environment.
184 (<a href="/+help/home-page-staging-help.html" target="help">What's this?</a>)<br />184 (<a href="/+help/home-page-staging-help.html" target="help">What's this?</a>)<br />
185 <tal:logged_in condition="view/user" omit-tag="">185 <tal:logged_in condition="view/user" omit-tag=""
186 tal:content="cache:public">
186 If you're ready, you can:187 If you're ready, you can:
187 <ul tal:define="apphomes view/apphomes">188 <ul tal:define="apphomes view/apphomes">
188 <li><a href="https://help.launchpad.net/">189 <li><a href="https://help.launchpad.net/">
@@ -207,7 +208,10 @@
207 </tal:logged_in>208 </tal:logged_in>
208 </div>209 </div>
209210
210 <div id="homepage-featured" class="homepage-portlet">211 <div id="homepage-featured" class="homepage-portlet"
212 tal:content="cache:anonymous, 1 hour">
213 <tal:cache
214 tal:content="cache:public, 5 minutes" tal:omit-tag="">
211 <h2>Featured projects</h2>215 <h2>Featured projects</h2>
212216
213 <div class="featured-project-top"217 <div class="featured-project-top"
@@ -231,9 +235,10 @@
231 </li>235 </li>
232 </ul>236 </ul>
233 </div>237 </div>
238 </tal:cache>
234239
235 <ul class="horizontal">240 <ul class="horizontal">
236 <li>241 <li tal:content="cache:public, 1 hour">
237 <strong><a href="/projects">Browse all242 <strong><a href="/projects">Browse all
238 <tal:count content="view/project_count">42</tal:count>243 <tal:count content="view/project_count">42</tal:count>
239 projects</a>!</strong>244 projects</a>!</strong>
240245
=== modified file 'lib/lp/services/memcache/configure.zcml'
--- lib/lp/services/memcache/configure.zcml 2009-09-16 12:47:23 +0000
+++ lib/lp/services/memcache/configure.zcml 2010-03-06 08:12:37 +0000
@@ -5,10 +5,16 @@
5 xmlns="http://namespaces.zope.org/zope"5 xmlns="http://namespaces.zope.org/zope"
6 xmlns:browser="http://namespaces.zope.org/browser"6 xmlns:browser="http://namespaces.zope.org/browser"
7 xmlns:i18n="http://namespaces.zope.org/i18n"7 xmlns:i18n="http://namespaces.zope.org/i18n"
8 xmlns:tales="http://namespaces.zope.org/tales"
8 i18n_domain="launchpad">9 i18n_domain="launchpad">
10
11 <!-- Main memcache interface - the IMemcacheClient Utility -->
9 <utility12 <utility
10 provides="lp.services.memcache.interfaces.IMemcacheClient"13 provides="lp.services.memcache.interfaces.IMemcacheClient"
11 factory="lp.services.memcache.client.memcache_client_factory"14 factory="lp.services.memcache.client.memcache_client_factory"
12 />15 />
16
17 <!-- TALES expression letting us cache chunks of rendered templates -->
18 <tales:expressiontype name="cache" handler=".tales.MemcacheExpr" />
13</configure>19</configure>
1420
1521
=== added directory 'lib/lp/services/memcache/doc'
=== added file 'lib/lp/services/memcache/doc/tales-cache.txt'
--- lib/lp/services/memcache/doc/tales-cache.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/memcache/doc/tales-cache.txt 2010-03-06 08:12:37 +0000
@@ -0,0 +1,214 @@
1Memcache with TALES
2===================
3
4We have extended TALES with a cache: expression to allow chunks of
5rendered page templates to be cached in Memcached.
6
7
8 >>> template = TestPageTemplate(dedent("""\
9 ... <div tal:content="cache:public">
10 ... <span tal:content="param">placeholder</span>
11 ... </div>"""))
12
13
14The first time we render the page template, there is no information
15in the cache. The cachable section is interpreted and stored in the cache
16for next time.
17
18 >>> print template(param='first')
19 <div>
20 <span>first</span>
21 </div>
22
23
24The second time we render the page template, the cached information
25is used. We prove this here by changing our parameters, which would
26cause this template to render differently.
27
28 >>> print template(param='second')
29 <div>
30 <span>first</span>
31 </div>
32
33
34If we clear the cache, it will be rendered as expected.
35
36 >>> MemcachedLayer.purge()
37 >>> print template(param='third')
38 <div>
39 <span>third</span>
40 </div>
41
42
43Expiry
44------
45
46We can specify how long cached information is considered valid. If
47this is not set, the information may be cached indefinitely. Note
48that memcache may evict information sooner if it runs low on storage
49space.
50
51One interesting technique is to specify a lengthy expiry, but to
52refresh the information asynchronously using an AJAX request. This
53is good enough for bots and improves the initial page load time, but
54care will be needed to avoid 'popping'.
55
56 >>> template = TestPageTemplate(dedent("""\
57 ... <body tal:omit-tag="">
58 ... <div tal:content="cache:public,30 seconds" tal:omit-tag="">
59 ... This bit cached up to 30 seconds.
60 ... </div>
61 ... <div tal:content="cache:public,1 minute" tal:omit-tag="">
62 ... This bit cached up to 1 minute.
63 ... </div>
64 ... <div tal:content="cache:public,6 hours" tal:omit-tag="">
65 ... This bit cached up to 6 hours.
66 ... </div>
67 ... <tal:cached content="cache:public,3 days">
68 ... This bit cached up to 3 days.
69 ... </tal:cached>
70 ... </body>"""))
71 >>> print template()
72 This bit cached up to 30 seconds.
73 This bit cached up to 1 minute.
74 This bit cached up to 6 hours.
75 This bit cached up to 3 days.
76
77
78Visibility
79----------
80
81We define 4 different types of 'visibility':
82
83 public
84
85 The cached information is shared by everyone. These sections
86 should not be personalized. They can contain private information
87 if the page itself is protected.
88
89 private
90
91 Unauthenticated users share cached information, but
92 authenticated users do not share with anyone else. A list on a
93 publicly accessible page that might contain private information
94 should use this visibility.
95
96 anonymous
97
98 Unauthenticated users share cached information, but
99 authenticated users do not use the cache at all. This can
100 be used to feed bots cached information quicky, while giving
101 authenticated users up to date information. In practice, this
102 might not make much difference as reverse proxies should
103 already be caching the entire page for unauthenticated users.
104
105 authenticated
106
107 Unauthenticated users share cached information, and all
108 authenticated users share a different cache. This is used
109 when information is being hidden from unauthenticated users,
110 for example when we hide email addresses from unauthenticated
111 users to help protect against email address harvesters.
112
113 >>> template = TestPageTemplate(dedent("""\
114 ... <div tal:omit-tag="">
115 ... <tal:cache content="cache:public">
116 ... Public: <tal:x content="username" />
117 ... </tal:cache>
118 ... <tal:cache content="cache:private">
119 ... Private: <tal:x content="username" />
120 ... </tal:cache>
121 ... <tal:cache content="cache:anonymous">
122 ... Anonymous: <tal:x content="username" />
123 ... </tal:cache>
124 ... <tal:cache content="cache:authenticated">
125 ... Authenticated: <tal:x content="username" />
126 ... </tal:cache>
127 ... </div>"""))
128
129Here we populate all caches.
130
131 >>> login(ANONYMOUS)
132 >>> print template(username="Anonymous")
133 Public: Anonymous
134 Private: Anonymous
135 Anonymous: Anonymous
136 Authenticated: Anonymous
137
138Here we reuse the public cache, populate foo's private cache,
139and populate the authenticated cache. The anonymous section is
140uncached.
141
142 >>> login('foo.bar@canonical.com')
143 >>> print template(username='Foo Bar')
144 Public: Anonymous
145 Private: Foo Bar
146 Anonymous: Foo Bar
147 Authenticated: Foo Bar
148
149Here we reuse the public cache, populate test's private cache, and
150reuse the authenticated cache. The anonymous section is uncached.
151
152 >>> login('test@canonical.com')
153 >>> print template(username='Test')
154 Public: Anonymous
155 Private: Test
156 Anonymous: Test
157 Authenticated: Foo Bar
158
159
160Nesting & Loops
161---------------
162
163Cached chunks can contain other cached chunks, useful for specifying
164different timeouts of different visibilities.
165
166 >>> template = TestPageTemplate(dedent("""\
167 ... <body tal:content="cache:private,25 seconds" tal:omit-tag="">
168 ... This bit is private to <span tal:replace="username" />
169 ... and cached up to 25 seconds, but contains
170 ... <span tal:content="cache:public,3 days" tal:omit-tag="">
171 ... this bit cached by <span tal:replace="username" />
172 ... which is public and cached up to 3 days.
173 ... </span>
174 ... </body>"""))
175
176 >>> login('foo.bar@canonical.com')
177 >>> print template(username="Foo Bar")
178 This bit is private to Foo Bar and cached up to 25 seconds, but
179 contains this bit cached by Foo Bar which is public and cached up
180 to 3 days.
181
182 >>> login('test@canonical.com')
183 >>> print template(username="Test")
184 This bit is private to Test and cached up to 25 seconds, but
185 contains this bit cached by Foo Bar which is public and cached up
186 to 3 days.
187
188
189tal:repeat loops are fully supported. Each iteration of the loop gets
190a different cache.
191
192 >>> template = TestPageTemplate(dedent("""\
193 ... <body>
194 ... <div tal:repeat="i python:range(1,3)">
195 ... <div tal:replace="cache:public">
196 ... <span tal:replace="param" />
197 ... <span tal:replace="repeat/i/index" />
198 ... </div>
199 ... </div>
200 ... </body>"""))
201 >>> print template(param='first')
202 <body>
203 <div> first 0 </div>
204 <div> first 1 </div>
205 </body>
206
207 >>> print template(param='second')
208 <body>
209 <div> first 0 </div>
210 <div> first 1 </div>
211 </body>
212
213
214
0215
=== modified file 'lib/lp/services/memcache/interfaces.py'
--- lib/lp/services/memcache/interfaces.py 2009-09-16 12:47:23 +0000
+++ lib/lp/services/memcache/interfaces.py 2010-03-06 08:12:37 +0000
@@ -4,7 +4,7 @@
4"""Memcached interfaces."""4"""Memcached interfaces."""
55
6__metaclass__ = type6__metaclass__ = type
7__all__ = []7__all__ = ['IMemcacheClient']
88
9from zope.interface import Interface9from zope.interface import Interface
1010
1111
=== added file 'lib/lp/services/memcache/tales.py'
--- lib/lp/services/memcache/tales.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/memcache/tales.py 2010-03-06 08:12:37 +0000
@@ -0,0 +1,294 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Implementation of the cache: namespace in TALES."""
5
6__metaclass__ = type
7__all__ = []
8
9
10from hashlib import md5
11import logging
12import os.path
13
14from zope.component import getUtility
15from zope.interface import implements
16from zope.tal.talinterpreter import TALInterpreter, I18nMessageTypes
17from zope.tales.interfaces import ITALESExpression
18
19from canonical.base import base
20from canonical.config import config
21from canonical.launchpad import versioninfo
22from canonical.launchpad.webapp.interfaces import ILaunchBag
23from lp.services.memcache.interfaces import IMemcacheClient
24
25
26class MemcacheExpr:
27 """Namespace to provide memcache caching of page template chunks.
28
29 This namespace is exclusively used in tal:content directives.
30 The only sensible way of using this is the following syntax:
31
32 <div tal:content="cache:public, 1 hour">
33 [... Potentially expensive page template chunk ...]
34 </div>
35 """
36 implements(ITALESExpression)
37 def __init__(self, name, expr, engine):
38 """expr is in the format "visibility, 42 units".
39
40 visibility is one of...
41
42 public: All users see the same cached information.
43
44 private: Authenticated users see a personal copy of the cached
45 information. Unauthenticated users share a copy of
46 the cached information.
47
48 anonymous: Unauthenticated users use a shared copy of the
49 cached information. Authenticated users don't
50 use the cache. This probably isn't that useful
51 in practice, as Anonymous requests should already
52 be cached by reverse proxies on the production
53 systems.
54
55 authenticated: Authenticated user share a copy of the cached
56 information, and unauthenticated users share
57 a seperate copy. Use this when information is
58 being hidden from unauthenticated users, eg.
59 for bug comments where email addresses are
60 obfuscated for unauthenticated users.
61
62 units is one of 'seconds', 'minutes', 'hours' or 'days'.
63
64 visibility is required. If the cache timeout is not specified,
65 it defaults to 'never timeout' (memcache will still purge the
66 information when in a LRU fashion when things fill up).
67 """
68 self._s = expr
69
70 if ',' in expr:
71 try:
72 self.visibility, max_age = (s.strip() for s in expr.split(','))
73 except ValueError:
74 raise SyntaxError("Too many arguments in cache: expression")
75 else:
76 self.visibility = expr.strip()
77 max_age = None
78 assert self.visibility in (
79 'anonymous', 'public', 'private', 'authenticated',
80 ), 'visibility must be anonymous, public, private or authenticated'
81
82 if max_age is None:
83 self.max_age = 0
84 else:
85 try:
86 value, unit = max_age.split(' ')
87 except ValueError:
88 raise SyntaxError(
89 "Unparsable age %s in cache: expression"
90 % repr(self.max_age))
91 value = float(value)
92 if unit[-1] == 's':
93 unit = unit[:-1]
94 if unit == 'second':
95 pass
96 elif unit == 'minute':
97 value *= 60
98 elif unit == 'hour':
99 value *= 60 * 60
100 elif unit == 'day':
101 value *= 24 * 60 * 60
102 else:
103 raise AssertionError("Unknown unit %s" % unit)
104 self.max_age = int(value)
105
106 # For use with str.translate to sanitize keys. No control characters
107 # allowed, and we skip ':' too since it is a magic separator.
108 _key_translate_map = (
109 '_'*33 + ''.join(chr(i) for i in range(33, ord(':'))) + '_'
110 + ''.join(chr(i) for i in range(ord(':')+1, 127)) + '_' * 129)
111
112 def getKey(self, econtext):
113 """We need to calculate a unique key for this cached chunk.
114
115 To ensure content is uniquely identified, we must include:
116 - a user id if this chunk is not 'public'
117 - the template source file name
118 - the position in the source file
119 - a counter to cope with cached chunks in loops
120 - the revision number of the source tree
121 - the config in use
122 - the URL and query string
123 """
124 # We include the URL and query string in the key.
125 # We use the full, unadulterated url to calculate a hash.
126 # We use a sanitized version in the human readable chunk of
127 # the key.
128 request = econtext.getValue('request')
129 url = str(request.URL) + '?' + str(request.get('QUERY_STRING', ''))
130 url = url.encode('utf8') # Ensure it is a byte string.
131 sanitized_url = url.translate(self._key_translate_map)
132
133 # We include the source file and position in the source file in
134 # the key.
135 source_file = os.path.abspath(econtext.source_file)
136 source_file = source_file[
137 len(os.path.commonprefix([source_file, config.root + '/lib']))+1:]
138
139 # We include the visibility in the key so private information
140 # is not leaked. We use 'p' for public information, 'a' for
141 # unauthenticated user information, 'l' for information shared
142 # between all authenticated users, or ${Person.id} for private
143 # information.
144 if self.visibility == 'public':
145 uid = 'p'
146 else:
147 logged_in_user = getUtility(ILaunchBag).user
148 if logged_in_user is None:
149 uid = 'a'
150 elif self.visibility == 'authenticated':
151 uid = 'l'
152 else: # private visibility
153 uid = str(logged_in_user.id)
154
155 # We include a counter in the key, reset at the start of the
156 # request, to ensure we get unique but repeatable keys inside
157 # tal:repeat loops.
158 counter_key = 'lp.services.memcache.tales.counter'
159 counter = request.annotations.get(counter_key, 0) + 1
160 request.annotations[counter_key] = counter
161
162 # We use pt: as a unique prefix to ensure no clashes with other
163 # components using the memcached servers. The order of components
164 # below only matters for human readability and memcached reporting
165 # tools - it doesn't really matter provided all the components are
166 # included and separators used.
167 key = "pt:%s:%s,%s:%s:%d,%d:%d,%s" % (
168 config.instance_name,
169 source_file, versioninfo.revno, uid,
170 econtext.position[0], econtext.position[1], counter,
171 sanitized_url,
172 )
173
174 # Memcached max key length is 250, so truncate but ensure uniqueness
175 # with a hash. A short hash is good, provided it is still unique,
176 # to preserve readability as much as possible. We include the
177 # unsanitized URL in the hash to ensure uniqueness.
178 key_hash = base(int(md5(key + url).hexdigest(), 16), 62)
179 key = key[:250-len(key_hash)] + key_hash
180
181 return key
182
183 def __call__(self, econtext):
184
185 # If we have an 'anonymous' visibility chunk and are logged in,
186 # we don't cache. Return the 'default' magic token to interpret
187 # the contents.
188 request = econtext.getValue('request')
189 if (self.visibility == 'anonymous'
190 and getUtility(ILaunchBag).user is not None):
191 return econtext.getDefault()
192
193 # Calculate a unique key so we serve the right cached information.
194 key = self.getKey(econtext)
195
196 cached_chunk = getUtility(IMemcacheClient).get(key)
197
198 if cached_chunk is None:
199 logging.debug("Memcache miss for %s", key)
200 return MemcacheMiss(key, self.max_age)
201 else:
202 logging.debug("Memcache hit for %s", key)
203 return MemcacheHit(cached_chunk)
204
205 def __str__(self):
206 return 'memcache expression (%s)' % self._s
207
208 def __repr__(self):
209 return '<MemcacheExpr %s>' % self._s
210
211
212class MemcacheMiss:
213 """Callback for the customized TALInterpreter to invoke.
214
215 If the memcache hit failed, the TALInterpreter interprets the
216 tag contents and invokes this callback, which will store the
217 result in memcache against the key calculated by the MemcacheExpr.
218 """
219 def __init__(self, key, max_age):
220 self._key = key
221 self._max_age = max_age
222
223 def __call__(self, value):
224 if getUtility(IMemcacheClient).set(
225 self._key, value, self._max_age):
226 logging.debug("Memcache set succeeded for %s", self._key)
227 else:
228 logging.warn("Memcache set failed for %s", self._key)
229
230 def __repr__(self):
231 return "<MemcacheCallback %s %d>" % (self._key, self._max_age)
232
233
234class MemcacheHit:
235 """A prerendered chunk retrieved from cache.
236
237 We use a special object so the TALInterpreter knows that this
238 information should not be quoted.
239 """
240 def __init__(self, value):
241 self.value = value
242
243
244# Oh my bleeding eyes! Monkey patching & cargo culting seems the sanest
245# way of installing our extensions, which makes me sad.
246
247def do_insertText_tal(self, stuff):
248 text = self.engine.evaluateText(stuff[0])
249 if text is None:
250 return
251 if text is self.Default:
252 self.interpret(stuff[1])
253 return
254 # Start Launchpad customization
255 if isinstance(text, MemcacheMiss):
256 # We got a MemcacheCallback instance. This means we hit a
257 # content="cache:..." attribute but there was no valid
258 # data in memcache. So we need to interpret the enclosed
259 # chunk of template and stuff it in the cache for next time.
260 callback = text
261 self.pushStream(self.StringIO())
262 self.interpret(stuff[1])
263 text = self.stream.getvalue()
264 self.popStream()
265 # Now we have generated the chunk, cache it for next time.
266 callback(text)
267 # And output it to the currently rendered page, unquoted.
268 self.stream_write(text)
269 return
270 if isinstance(text, MemcacheHit):
271 # Got a hit. Include the contents directly into the
272 # rendered page, unquoted.
273 self.stream_write(text.value)
274 return
275 # End Launchpad customization
276 if isinstance(text, I18nMessageTypes):
277 # Translate this now.
278 text = self.translate(text)
279 self._writeText(text)
280TALInterpreter.bytecode_handlers_tal["insertText"] = do_insertText_tal
281
282
283# Just like the original, except MemcacheHit and MemcacheMiss
284# instances are also passed through unharmed.
285def evaluateText(self, expr):
286 text = self.evaluate(expr)
287 if (text is None
288 or isinstance(text, (basestring, MemcacheHit, MemcacheMiss))
289 or text is self.getDefault()):
290 return text
291 return unicode(text)
292import zope.pagetemplate.engine
293zope.pagetemplate.engine.ZopeContextBase.evaluateText = evaluateText
294
0295
=== added file 'lib/lp/services/memcache/tests/test_doc.py'
--- lib/lp/services/memcache/tests/test_doc.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/memcache/tests/test_doc.py 2010-03-06 08:12:37 +0000
@@ -0,0 +1,71 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Run doctests."""
5
6__metaclass__ = type
7
8import os.path
9from textwrap import dedent
10import unittest
11
12from zope.component import getUtility
13import zope.pagetemplate.engine
14from zope.pagetemplate.pagetemplate import PageTemplate
15from zope.publisher.browser import TestRequest
16
17from canonical.launchpad.testing.systemdocs import (
18 LayeredDocFileSuite, setUp, tearDown)
19from canonical.testing.layers import LaunchpadFunctionalLayer, MemcachedLayer
20from lp.services.memcache.interfaces import IMemcacheClient
21from lp.services.testing import build_test_suite
22from lp.testing import TestCase
23
24
25here = os.path.dirname(os.path.realpath(__file__))
26
27
28class TestPageTemplate(PageTemplate):
29 """A cutdown PageTemplate implementation suitable for our tests."""
30
31 _num_instances = 0
32
33 def __init__(self, source):
34 super(TestPageTemplate, self).__init__()
35 TestPageTemplate._num_instances += 1
36 self._my_instance_num = TestPageTemplate._num_instances
37 self.pt_edit(source, 'text/html')
38
39 def pt_source_file(self):
40 return 'fake/test_%d.pt' % self._my_instance_num
41
42 def pt_getEngine(self):
43 # The <tales:expressiontype> ZCML only registers with this
44 # engine, not the default.
45 return zope.pagetemplate.engine.Engine
46
47 def pt_getContext(self, args=(), options={}):
48 # Build a minimal context. The cache: expression requires
49 # a request.
50 context = {'request': TestRequest()}
51 context.update(options)
52 return context
53
54
55def memcacheSetUp(test):
56 setUp(test)
57 test.globs['TestPageTemplate'] = TestPageTemplate
58 test.globs['dedent'] = dedent
59 test.globs['MemcachedLayer'] = MemcachedLayer
60
61
62special = {
63 'tales-cache.txt': LayeredDocFileSuite(
64 '../doc/tales-cache.txt',
65 setUp=memcacheSetUp, tearDown=tearDown,
66 layer=LaunchpadFunctionalLayer),
67 }
68
69
70def test_suite():
71 return build_test_suite(here, special, layer=LaunchpadFunctionalLayer)