Skip to content

Commit 4628359

Browse files
committed
Add per-request cache for registry value and proxy lookups
Cache OOBTree value lookups and forInterface proxy objects on the request to avoid repeated B-tree traversals within a single request.
1 parent f09c737 commit 4628359

File tree

2 files changed

+316
-7
lines changed

2 files changed

+316
-7
lines changed

src/plone/registry/registry.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@
2020
import re
2121
import warnings
2222

23+
_CACHE_MARKER = object()
24+
25+
26+
def _get_request_cache(context):
27+
"""Return the per-request value cache dict, or None if no request.
28+
29+
Uses context.REQUEST (acquisition) to avoid any ZCA or thread-local
30+
lookup overhead.
31+
"""
32+
request = getattr(context, "REQUEST", None)
33+
if request is None or not hasattr(request, "__dict__"):
34+
return None
35+
cache = getattr(request, "_plone_registry_cache", None)
36+
if cache is None:
37+
cache = {}
38+
request._plone_registry_cache = cache
39+
return cache
40+
2341

2442
@implementer(IRegistry)
2543
class Registry(Persistent):
@@ -31,20 +49,40 @@ def __init__(self):
3149
# Basic value access API
3250

3351
def __getitem__(self, name):
34-
# Fetch straight from records._values to avoid loading the field
35-
# as a separate persistent object
36-
return self.records._values[name]
52+
cache = _get_request_cache(self)
53+
if cache is not None:
54+
value = cache.get(name, _CACHE_MARKER)
55+
if value is not _CACHE_MARKER:
56+
return value
57+
value = self.records._values[name]
58+
if cache is not None:
59+
cache[name] = value
60+
return value
3761

3862
def get(self, name, default=None):
39-
# Fetch straight from records._values to avoid loading the field
40-
# as a separate persistent object
41-
return self.records._values.get(name, default)
63+
cache = _get_request_cache(self)
64+
if cache is not None:
65+
value = cache.get(name, _CACHE_MARKER)
66+
if value is not _CACHE_MARKER:
67+
return value
68+
value = self.records._values.get(name, _CACHE_MARKER)
69+
if value is _CACHE_MARKER:
70+
return default
71+
if cache is not None:
72+
cache[name] = value
73+
return value
4274

4375
def __setitem__(self, name, value):
4476
# make sure we get the Record class' validation
4577
self.records[name].value = value
78+
cache = _get_request_cache(self)
79+
if cache is not None:
80+
cache[name] = value
4681

4782
def __contains__(self, name):
83+
cache = _get_request_cache(self)
84+
if cache is not None and name in cache:
85+
return True
4886
return name in self.records._values
4987

5088
# Records - make this a property so that it's readonly
@@ -65,6 +103,13 @@ def forInterface(self, interface, check=True, omit=(), prefix=None, factory=None
65103
if not prefix.endswith("."):
66104
prefix += "."
67105

106+
cache = _get_request_cache(self)
107+
cache_key = ("__proxy__", interface.__identifier__, prefix, omit)
108+
if cache is not None:
109+
proxy = cache.get(cache_key)
110+
if proxy is not None:
111+
return proxy
112+
68113
if check:
69114
for name in getFieldNames(interface):
70115
if name not in omit and prefix + name not in self:
@@ -76,7 +121,12 @@ def forInterface(self, interface, check=True, omit=(), prefix=None, factory=None
76121
if factory is None:
77122
factory = RecordsProxy
78123

79-
return factory(self, interface, omitted=omit, prefix=prefix)
124+
proxy = factory(self, interface, omitted=omit, prefix=prefix)
125+
126+
if cache is not None:
127+
cache[cache_key] = proxy
128+
129+
return proxy
80130

81131
def registerInterface(self, interface, omit=(), prefix=None):
82132
if prefix is None:

src/plone/registry/tests.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,263 @@ def test_auto_migration(self):
141141
self.assertTrue(isinstance(registry._records, _Records))
142142

143143

144+
class FakeRequest:
145+
"""Minimal request-like object for testing request-level caching."""
146+
147+
def __init__(self):
148+
self.environ = {}
149+
150+
151+
class TestRequestValueCache(unittest.TestCase):
152+
"""Tests for request-level value caching in Registry."""
153+
154+
def setUp(self):
155+
setUp(self)
156+
from plone.registry import field
157+
from plone.registry.record import Record
158+
from plone.registry.registry import Registry
159+
160+
self.registry = Registry()
161+
self.registry.records["test.key1"] = Record(
162+
field.TextLine(title="Key 1"), "value1"
163+
)
164+
self.registry.records["test.key2"] = Record(
165+
field.TextLine(title="Key 2"), "value2"
166+
)
167+
self.registry.records["test.none"] = Record(
168+
field.TextLine(title="None val", required=False), None
169+
)
170+
self.request = FakeRequest()
171+
172+
def tearDown(self):
173+
if hasattr(self.registry, "REQUEST"):
174+
del self.registry.REQUEST
175+
testing.tearDown(self)
176+
177+
def _setRequest(self):
178+
self.registry.REQUEST = self.request
179+
180+
# --- __getitem__ tests ---
181+
182+
def test_getitem_no_request(self):
183+
"""Without a request, values are fetched directly from OOBTree."""
184+
self.assertEqual(self.registry["test.key1"], "value1")
185+
self.assertFalse(hasattr(self.request, "_plone_registry_cache"))
186+
187+
def test_getitem_populates_cache(self):
188+
"""First read with a request populates the cache."""
189+
self._setRequest()
190+
self.assertEqual(self.registry["test.key1"], "value1")
191+
self.assertIn("test.key1", self.request._plone_registry_cache)
192+
self.assertEqual(self.request._plone_registry_cache["test.key1"], "value1")
193+
194+
def test_getitem_serves_from_cache(self):
195+
"""Subsequent reads return the cached value."""
196+
self._setRequest()
197+
self.request._plone_registry_cache = {"test.key1": "cached_value"}
198+
self.assertEqual(self.registry["test.key1"], "cached_value")
199+
200+
def test_getitem_keyerror_not_cached(self):
201+
"""KeyError for missing keys propagates without caching."""
202+
self._setRequest()
203+
with self.assertRaises(KeyError):
204+
self.registry["nonexistent.key"]
205+
self.assertNotIn("nonexistent.key", self.request._plone_registry_cache)
206+
207+
def test_getitem_multiple_keys(self):
208+
"""Multiple keys are independently cached."""
209+
self._setRequest()
210+
self.assertEqual(self.registry["test.key1"], "value1")
211+
self.assertEqual(self.registry["test.key2"], "value2")
212+
cache = self.request._plone_registry_cache
213+
self.assertEqual(cache["test.key1"], "value1")
214+
self.assertEqual(cache["test.key2"], "value2")
215+
216+
# --- get() tests ---
217+
218+
def test_get_no_request(self):
219+
"""Without a request, get() works normally."""
220+
self.assertEqual(self.registry.get("test.key1"), "value1")
221+
self.assertEqual(self.registry.get("nonexistent", "default"), "default")
222+
223+
def test_get_populates_cache(self):
224+
"""get() populates the cache on first access."""
225+
self._setRequest()
226+
self.assertEqual(self.registry.get("test.key1"), "value1")
227+
self.assertIn("test.key1", self.request._plone_registry_cache)
228+
229+
def test_get_serves_from_cache(self):
230+
"""get() returns cached value on subsequent access."""
231+
self._setRequest()
232+
self.request._plone_registry_cache = {"test.key1": "cached"}
233+
self.assertEqual(self.registry.get("test.key1"), "cached")
234+
235+
def test_get_default_not_cached(self):
236+
"""get() with missing key returns default and does not cache it."""
237+
self._setRequest()
238+
result = self.registry.get("nonexistent", "default")
239+
self.assertEqual(result, "default")
240+
self.assertNotIn("nonexistent", self.request._plone_registry_cache)
241+
242+
def test_get_none_value_cached(self):
243+
"""None values are correctly cached (not confused with sentinel)."""
244+
self._setRequest()
245+
result = self.registry.get("test.none")
246+
self.assertIsNone(result)
247+
self.assertIn("test.none", self.request._plone_registry_cache)
248+
self.assertIsNone(self.registry.get("test.none"))
249+
250+
def test_get_none_default(self):
251+
"""get() returns None default for missing keys without caching."""
252+
self._setRequest()
253+
result = self.registry.get("nonexistent")
254+
self.assertIsNone(result)
255+
self.assertNotIn("nonexistent", self.request._plone_registry_cache)
256+
257+
# --- __setitem__ tests ---
258+
259+
def test_setitem_updates_cache(self):
260+
"""Writing a value updates the cache."""
261+
self._setRequest()
262+
self.assertEqual(self.registry["test.key1"], "value1")
263+
self.registry["test.key1"] = "new_value"
264+
self.assertEqual(self.request._plone_registry_cache["test.key1"], "new_value")
265+
self.assertEqual(self.registry["test.key1"], "new_value")
266+
267+
def test_setitem_no_request(self):
268+
"""Writing without a request works normally."""
269+
self.registry["test.key1"] = "new_value"
270+
self.assertEqual(self.registry["test.key1"], "new_value")
271+
272+
# --- Cache isolation ---
273+
274+
def test_cache_isolation_between_requests(self):
275+
"""Different requests have independent caches."""
276+
self._setRequest()
277+
self.registry["test.key1"] # populate cache
278+
279+
new_request = FakeRequest()
280+
self.registry.REQUEST = new_request
281+
self.assertFalse(hasattr(new_request, "_plone_registry_cache"))
282+
283+
self.assertEqual(self.registry["test.key1"], "value1")
284+
self.assertIn("test.key1", new_request._plone_registry_cache)
285+
self.assertIn("test.key1", self.request._plone_registry_cache)
286+
287+
def test_cache_lazily_created(self):
288+
"""Cache dict is only created on first registry access."""
289+
self._setRequest()
290+
self.assertFalse(hasattr(self.request, "_plone_registry_cache"))
291+
self.registry["test.key1"]
292+
self.assertTrue(hasattr(self.request, "_plone_registry_cache"))
293+
294+
# --- __contains__ tests ---
295+
296+
def test_contains_no_request(self):
297+
"""Without a request, __contains__ works normally."""
298+
self.assertIn("test.key1", self.registry)
299+
self.assertNotIn("nonexistent", self.registry)
300+
301+
def test_contains_uses_value_cache(self):
302+
"""__contains__ returns True if key is in the value cache."""
303+
self._setRequest()
304+
self.registry.get("test.key1")
305+
self.assertIn("test.key1", self.registry)
306+
307+
def test_contains_falls_through_to_oobtree(self):
308+
"""__contains__ falls through to OOBTree for uncached keys."""
309+
self._setRequest()
310+
self.assertIn("test.key2", self.registry)
311+
self.assertNotIn("nonexistent", self.registry)
312+
313+
314+
class TestForInterfaceCache(unittest.TestCase):
315+
"""Tests for forInterface() proxy caching."""
316+
317+
def setUp(self):
318+
setUp(self)
319+
from plone.registry.registry import Registry
320+
321+
self.registry = Registry()
322+
self.registry.registerInterface(IMailSettings)
323+
self.request = FakeRequest()
324+
325+
def tearDown(self):
326+
if hasattr(self.registry, "REQUEST"):
327+
del self.registry.REQUEST
328+
testing.tearDown(self)
329+
330+
def _setRequest(self):
331+
self.registry.REQUEST = self.request
332+
333+
def test_forinterface_no_request(self):
334+
"""Without a request, forInterface works normally."""
335+
proxy = self.registry.forInterface(IMailSettings)
336+
self.assertTrue(IMailSettings.providedBy(proxy))
337+
338+
def test_forinterface_caches_proxy(self):
339+
"""With a request, forInterface caches the proxy."""
340+
self._setRequest()
341+
proxy1 = self.registry.forInterface(IMailSettings)
342+
proxy2 = self.registry.forInterface(IMailSettings)
343+
self.assertIs(proxy1, proxy2)
344+
345+
def test_forinterface_cache_hit_skips_check(self):
346+
"""Cached proxy avoids the field existence check."""
347+
self._setRequest()
348+
proxy1 = self.registry.forInterface(IMailSettings)
349+
prefix = IMailSettings.__identifier__ + "."
350+
key = prefix + "sender"
351+
del self.registry.records._values[key]
352+
del self.registry.records._fields[key]
353+
proxy2 = self.registry.forInterface(IMailSettings)
354+
self.assertIs(proxy1, proxy2)
355+
356+
def test_forinterface_same_prefix_different_interface_not_shared(self):
357+
"""Different interfaces with same prefix get separate proxies."""
358+
self._setRequest()
359+
proxy1 = self.registry.forInterface(
360+
IMailSettings, check=False, prefix="shared.prefix"
361+
)
362+
proxy2 = self.registry.forInterface(
363+
IMailPreferences, check=False, prefix="shared.prefix"
364+
)
365+
self.assertIsNot(proxy1, proxy2)
366+
self.assertTrue(IMailSettings.providedBy(proxy1))
367+
self.assertTrue(IMailPreferences.providedBy(proxy2))
368+
369+
def test_forinterface_different_prefix_not_shared(self):
370+
"""Different prefixes get separate cached proxies."""
371+
self._setRequest()
372+
self.registry.registerInterface(IMailSettings, prefix="alt.settings")
373+
proxy1 = self.registry.forInterface(IMailSettings)
374+
proxy2 = self.registry.forInterface(IMailSettings, prefix="alt.settings")
375+
self.assertIsNot(proxy1, proxy2)
376+
377+
def test_forinterface_different_omit_not_shared(self):
378+
"""Different omit tuples get separate cached proxies."""
379+
self._setRequest()
380+
proxy1 = self.registry.forInterface(IMailSettings)
381+
proxy2 = self.registry.forInterface(IMailSettings, omit=("sender",))
382+
self.assertIsNot(proxy1, proxy2)
383+
384+
def test_forinterface_cache_isolation(self):
385+
"""Different requests have independent proxy caches."""
386+
self._setRequest()
387+
proxy1 = self.registry.forInterface(IMailSettings)
388+
389+
new_request = FakeRequest()
390+
self.registry.REQUEST = new_request
391+
proxy2 = self.registry.forInterface(IMailSettings)
392+
self.assertIsNot(proxy1, proxy2)
393+
394+
def test_forinterface_no_request_returns_new_each_time(self):
395+
"""Without a request, each call returns a fresh proxy."""
396+
proxy1 = self.registry.forInterface(IMailSettings)
397+
proxy2 = self.registry.forInterface(IMailSettings)
398+
self.assertIsNot(proxy1, proxy2)
399+
400+
144401
def test_suite():
145402
return unittest.TestSuite(
146403
[
@@ -170,5 +427,7 @@ def test_suite():
170427
),
171428
unittest.defaultTestLoader.loadTestsFromTestCase(TestBugs),
172429
unittest.defaultTestLoader.loadTestsFromTestCase(TestMigration),
430+
unittest.defaultTestLoader.loadTestsFromTestCase(TestRequestValueCache),
431+
unittest.defaultTestLoader.loadTestsFromTestCase(TestForInterfaceCache),
173432
]
174433
)

0 commit comments

Comments
 (0)