Merge lp:~stub/launchpad/memcache into lp:launchpad
- memcache
- Merge into devel
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 |
Related bugs: |
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.
Description of the change
Stuart Bishop (stub) wrote : | # |
Gary Poster (gary) wrote : | # |
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=
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_
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_
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:
Stuart Bishop (stub) wrote : | # |
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=
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_
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...
Preview Diff
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> 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) |
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.