Merge ~jugmac00/launchpad:fix-pymemcache-regression-bytes-vs-str into launchpad:master

Proposed by Jürgen Gmach
Status: Merged
Approved by: Jürgen Gmach
Approved revision: 2fd70b8d4840179b27e0ee973a9c27a4855c40ca
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~jugmac00/launchpad:fix-pymemcache-regression-bytes-vs-str
Merge into: launchpad:master
Diff against target: 291 lines (+88/-42)
7 files modified
lib/lp/code/model/branch.py (+7/-11)
lib/lp/code/model/gitref.py (+5/-12)
lib/lp/services/memcache/client.py (+44/-2)
lib/lp/services/memcache/testing.py (+2/-1)
lib/lp/services/memcache/tests/test_memcache_client.py (+20/-0)
lib/lp/services/memcache/timeline.py (+4/-4)
lib/lp/snappy/model/snapstoreclient.py (+6/-12)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+411913@code.launchpad.net

Commit message

Fix pymemcache regression in SnapStoreClient.listChannels

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

For the reviewer:
- I had to remove one assertion: self.assertEqual([], hosting_fixture.getInventory.calls), which I think is ok, as getInventory is the method were the expected exception is raised
- I had to implement the two new methods also in MemcacheFixture - this is a bit dangerous, as duplicated code could be changed in one place and not in the other, and we basically test MemcacheFixture rather than the real code.
- I have not created a dedicated test to test the behavior when the Client accesses bytes instead of the expected str - I could inject bytes in MemcacheFixture._cache, but then again, we test the fixture, not the real code.

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Jürgen Gmach (jugmac00) wrote (last edit ):

Thanks for the review. I think I applied all suggestions, with exception to the super vs direct class name thing, see comment below.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/model/branch.py b/lib/lp/code/model/branch.py
2index b915da8..12cfdf4 100644
3--- a/lib/lp/code/model/branch.py
4+++ b/lib/lp/code/model/branch.py
5@@ -9,7 +9,6 @@ __all__ = [
6
7 from datetime import datetime
8 from functools import partial
9-import json
10 import operator
11 import os.path
12
13@@ -833,15 +832,12 @@ class Branch(SQLBase, WebhookTargetMixin, BzrIdentityMixin):
14 memcache_key = six.ensure_binary(
15 '%s:bzr-file-list:%s:%s:%s' % (
16 instance_name, self.id, revision_id, dirname))
17- cached_file_list = memcache_client.get(memcache_key)
18- if cached_file_list is not None:
19- try:
20- file_list = json.loads(cached_file_list)
21- except Exception:
22- logger.exception(
23- 'Cannot load cached file list for %s:%s:%s; deleting' %
24- (self.unique_name, revision_id, dirname))
25- memcache_client.delete(memcache_key)
26+ description = "file list for %s:%s:%s" % (
27+ self.unique_name, revision_id, dirname
28+ )
29+ file_list = memcache_client.get_json(
30+ memcache_key, logger, description, default=unset)
31+
32 if file_list is unset:
33 try:
34 inventory = hosting_client.getInventory(
35@@ -854,7 +850,7 @@ class Branch(SQLBase, WebhookTargetMixin, BzrIdentityMixin):
36 if enable_memcache:
37 # Cache the file list in case there's a request for another
38 # file in the same directory.
39- memcache_client.set(memcache_key, json.dumps(file_list))
40+ memcache_client.set_json(memcache_key, file_list)
41 file_id = (file_list or {}).get(os.path.basename(filename))
42 if file_id is None:
43 raise BranchFileNotFound(
44diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py
45index ca4c05b..8571c4a 100644
46--- a/lib/lp/code/model/gitref.py
47+++ b/lib/lp/code/model/gitref.py
48@@ -9,7 +9,6 @@ __all__ = [
49 ]
50
51 from functools import partial
52-import json
53 import re
54
55 from lazr.lifecycle.event import ObjectCreatedEvent
56@@ -353,23 +352,17 @@ class GitRefMixin:
57 if stop is not None:
58 memcache_key += ":stop=%s" % stop
59 memcache_key = six.ensure_binary(memcache_key)
60- cached_log = memcache_client.get(memcache_key)
61- if cached_log is not None:
62- try:
63- log = json.loads(cached_log)
64- except Exception:
65- if logger is not None:
66- logger.exception(
67- "Cannot load cached log information for %s:%s; "
68- "deleting" % (path, start))
69- memcache_client.delete(memcache_key)
70+
71+ description = "log information for %s:%s" % (path, start)
72+ log = memcache_client.get_json(memcache_key, logger, description)
73+
74 if log is None:
75 if enable_hosting:
76 hosting_client = getUtility(IGitHostingClient)
77 log = removeSecurityProxy(hosting_client.getLog(
78 path, start, limit=limit, stop=stop, logger=logger))
79 if enable_memcache:
80- memcache_client.set(memcache_key, json.dumps(log))
81+ memcache_client.set_json(memcache_key, log)
82 else:
83 # Fall back to synthesising something reasonable based on
84 # information in our own database.
85diff --git a/lib/lp/services/memcache/client.py b/lib/lp/services/memcache/client.py
86index e04be16..55b9d03 100644
87--- a/lib/lp/services/memcache/client.py
88+++ b/lib/lp/services/memcache/client.py
89@@ -4,9 +4,11 @@
90 """Launchpad Memcache client."""
91
92 __all__ = [
93+ 'MemcacheClient',
94 'memcache_client_factory',
95 ]
96
97+import json
98 import re
99
100 from pymemcache.client.hash import HashClient
101@@ -18,8 +20,48 @@ from pymemcache.serde import (
102 from lp.services.config import config
103
104
105+class MemcacheClient(HashClient):
106+ """memcached client with added JSON handling"""
107+
108+ def get_json(self, key, logger, description, default=None):
109+ """Returns decoded JSON data from a memcache instance for a given key
110+
111+ In case of a decoding issue, and given a logger and a description, an
112+ error message gets logged.
113+
114+ The `default` value is used when no value could be retrieved, or an
115+ error happens.
116+
117+ :returns: dict or the default value
118+ """
119+ data = self.get(key)
120+ if data is not None:
121+ try:
122+ rv = json.loads(data)
123+ # the exceptions are chosen deliberately in order to gracefully
124+ # handle invalid data
125+ except (TypeError, ValueError):
126+ if logger and description:
127+ logger.exception(
128+ "Cannot load cached %s; deleting" % description
129+ )
130+ self.delete(key)
131+ rv = default
132+ else:
133+ rv = default
134+ return rv
135+
136+ def set_json(self, key, value, expire=0):
137+ """Saves the given key/value pair, after converting the value.
138+
139+ `expire` (optional int) is the number of seconds until the item
140+ expires from the cache; zero means no expiry.
141+ """
142+ self.set(key, json.dumps(value), expire)
143+
144+
145 def memcache_client_factory(timeline=True):
146- """Return a pymemcache HashClient for Launchpad."""
147+ """Return an extended pymemcache client for Launchpad."""
148 # example value for config.memcache.servers:
149 # (127.0.0.1:11242,1)
150 servers = [
151@@ -31,7 +73,7 @@ def memcache_client_factory(timeline=True):
152 from lp.services.memcache.timeline import TimelineRecordingClient
153 client_factory = TimelineRecordingClient
154 else:
155- client_factory = HashClient
156+ client_factory = MemcacheClient
157 return client_factory(
158 servers,
159 serializer=python_memcache_serializer,
160diff --git a/lib/lp/services/memcache/testing.py b/lib/lp/services/memcache/testing.py
161index c4809f4..23ef232 100644
162--- a/lib/lp/services/memcache/testing.py
163+++ b/lib/lp/services/memcache/testing.py
164@@ -9,11 +9,12 @@ import time as _time
165
166 import fixtures
167
168+from lp.services.memcache.client import MemcacheClient
169 from lp.services.memcache.interfaces import IMemcacheClient
170 from lp.testing.fixture import ZopeUtilityFixture
171
172
173-class MemcacheFixture(fixtures.Fixture):
174+class MemcacheFixture(fixtures.Fixture, MemcacheClient):
175 """A trivial in-process memcache fixture."""
176
177 def __init__(self):
178diff --git a/lib/lp/services/memcache/tests/test_memcache_client.py b/lib/lp/services/memcache/tests/test_memcache_client.py
179index ef93fff..ee49844 100644
180--- a/lib/lp/services/memcache/tests/test_memcache_client.py
181+++ b/lib/lp/services/memcache/tests/test_memcache_client.py
182@@ -7,8 +7,10 @@ from lazr.restful.utils import get_current_browser_request
183 from pymemcache.exceptions import MemcacheIllegalInputError
184 from zope.component import getUtility
185
186+from lp.services.log.logger import BufferLogger
187 from lp.services.memcache.client import memcache_client_factory
188 from lp.services.memcache.interfaces import IMemcacheClient
189+from lp.services.memcache.testing import MemcacheFixture
190 from lp.services.timeline.requesttimeline import get_request_timeline
191 from lp.testing import TestCase
192 from lp.testing.layers import LaunchpadZopelessLayer
193@@ -75,3 +77,21 @@ class MemcacheClientFactoryTestCase(TestCase):
194 client.set('foo', 'bar')
195 self.assertEqual('bar', client.get('foo'))
196 self.assertEqual(base_action_count, len(timeline.actions))
197+
198+
199+class MemcacheClientJSONTestCase(TestCase):
200+ def setUp(self):
201+ super().setUp()
202+ self.client = self.useFixture(MemcacheFixture())
203+ self.logger = BufferLogger()
204+
205+ def test_handle_invalid_data(self):
206+ self.client.set("key", b"invalid_data")
207+ description = "binary data"
208+
209+ self.client.get_json("key", self.logger, description)
210+
211+ self.assertEqual(
212+ "ERROR Cannot load cached binary data; deleting\n",
213+ self.logger.content.as_text()
214+ )
215diff --git a/lib/lp/services/memcache/timeline.py b/lib/lp/services/memcache/timeline.py
216index 9e1bf3e..3427e73 100644
217--- a/lib/lp/services/memcache/timeline.py
218+++ b/lib/lp/services/memcache/timeline.py
219@@ -10,13 +10,13 @@ __all__ = [
220 import logging
221
222 from lazr.restful.utils import get_current_browser_request
223-from pymemcache.client.hash import HashClient
224
225 from lp.services import features
226+from lp.services.memcache.client import MemcacheClient
227 from lp.services.timeline.requesttimeline import get_request_timeline
228
229
230-class TimelineRecordingClient(HashClient):
231+class TimelineRecordingClient(MemcacheClient):
232
233 def __get_timeline_action(self, suffix, key):
234 request = get_current_browser_request()
235@@ -36,7 +36,7 @@ class TimelineRecordingClient(HashClient):
236 return None
237 action = self.__get_timeline_action("get", key)
238 try:
239- return HashClient.get(self, key)
240+ return super().get(key)
241 finally:
242 action.finish()
243
244@@ -45,7 +45,7 @@ class TimelineRecordingClient(HashClient):
245 return None
246 action = self.__get_timeline_action("set", key)
247 try:
248- success = HashClient.set(self, key, value, expire=expire)
249+ success = super().set(key, value, expire=expire)
250 if success:
251 logging.debug("Memcache set succeeded for %s", key)
252 else:
253diff --git a/lib/lp/snappy/model/snapstoreclient.py b/lib/lp/snappy/model/snapstoreclient.py
254index c43d705..9532567 100644
255--- a/lib/lp/snappy/model/snapstoreclient.py
256+++ b/lib/lp/snappy/model/snapstoreclient.py
257@@ -390,19 +390,13 @@ class SnapStoreClient:
258 """See `ISnapStoreClient`."""
259 if config.snappy.store_search_url is None:
260 return _default_store_channels
261- channels = None
262 memcache_client = getUtility(IMemcacheClient)
263 search_host = urlsplit(config.snappy.store_search_url).hostname
264 memcache_key = ("%s:channels" % search_host).encode("UTF-8")
265- cached_channels = memcache_client.get(memcache_key)
266- if cached_channels is not None:
267- try:
268- channels = json.loads(cached_channels)
269- except ValueError:
270- log.exception(
271- "Cannot load cached channels for %s; deleting" %
272- search_host)
273- memcache_client.delete(memcache_key)
274+ description = "channels for %s" % search_host
275+
276+ channels = memcache_client.get_json(memcache_key, log, description)
277+
278 if (channels is None and
279 not getFeatureFlag(u"snap.disable_channel_search")):
280 path = "api/v1/channels"
281@@ -423,8 +417,8 @@ class SnapStoreClient:
282 DAY_IN_SECONDS = 60 * 60 * 24
283 # pymemcache's `expire` expects an int
284 expire_time = int(time.time()) + DAY_IN_SECONDS
285- memcache_client.set(
286- memcache_key, json.dumps(channels), expire_time)
287+ memcache_client.set_json(
288+ memcache_key, channels, expire_time)
289 if channels is None:
290 channels = _default_store_channels
291 return channels

Subscribers

People subscribed via source and target branches

to status/vote changes: