Skip to content

Commit 055c36e

Browse files
authored
feat: add service for query cache results (#17781)
* feat: add service for query cache results Using Redis as a backing store for serialized results of longer, expensive queries that can be computed periodically, and then used in a client-facing operation where the query would take to long inline. Signed-off-by: Mike Fiedler <[email protected]> * remove whitespace that'll teach me to resolve conflicts in the browser... --------- Signed-off-by: Mike Fiedler <[email protected]>
1 parent 231039e commit 055c36e

File tree

8 files changed

+224
-0
lines changed

8 files changed

+224
-0
lines changed

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
from warehouse.admin.flags import AdminFlag, AdminFlagValue
4949
from warehouse.attestations import services as attestations_services
5050
from warehouse.attestations.interfaces import IIntegrityService
51+
from warehouse.cache import services as cache_services
52+
from warehouse.cache.interfaces import IQueryResultsCache
5153
from warehouse.email import services as email_services
5254
from warehouse.email.interfaces import IEmailSender
5355
from warehouse.helpdesk import services as helpdesk_services
@@ -165,6 +167,7 @@ def pyramid_services(
165167
macaroon_service,
166168
helpdesk_service,
167169
notification_service,
170+
query_results_cache_service,
168171
search_service,
169172
):
170173
services = _Services()
@@ -189,6 +192,7 @@ def pyramid_services(
189192
services.register_service(macaroon_service, IMacaroonService, None, name="")
190193
services.register_service(helpdesk_service, IHelpDeskService, None)
191194
services.register_service(notification_service, IAdminNotificationService)
195+
services.register_service(query_results_cache_service, IQueryResultsCache)
192196
services.register_service(search_service, ISearchService)
193197

194198
return services
@@ -316,6 +320,7 @@ def get_app_config(database, nondefaults=None):
316320
"database.url": database,
317321
"docs.url": "http://docs.example.com/",
318322
"ratelimit.url": "memory://",
323+
"db_results_cache.url": "redis://localhost:0/",
319324
"opensearch.url": "https://localhost/warehouse",
320325
"files.backend": "warehouse.packaging.services.LocalFileStorage",
321326
"archive_files.backend": "warehouse.packaging.services.LocalArchiveFileStorage",
@@ -528,6 +533,11 @@ def notification_service():
528533
return helpdesk_services.ConsoleAdminNotificationService()
529534

530535

536+
@pytest.fixture
537+
def query_results_cache_service(mockredis):
538+
return cache_services.RedisQueryResults(redis_client=mockredis)
539+
540+
531541
@pytest.fixture
532542
def search_service():
533543
return search_services.NullSearchService()

tests/unit/cache/test_init.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import pretend
14+
15+
from warehouse.cache import includeme
16+
from warehouse.cache.interfaces import IQueryResultsCache
17+
from warehouse.cache.services import RedisQueryResults
18+
19+
20+
def test_includeme():
21+
config = pretend.stub(
22+
register_service_factory=pretend.call_recorder(lambda *a, **k: None)
23+
)
24+
25+
includeme(config)
26+
27+
assert config.register_service_factory.calls == [
28+
pretend.call(RedisQueryResults.create_service, IQueryResultsCache)
29+
]

tests/unit/cache/test_services.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import datetime
14+
import uuid
15+
16+
import pretend
17+
18+
from zope.interface.verify import verifyClass
19+
20+
from warehouse.cache.interfaces import IQueryResultsCache
21+
from warehouse.cache.services import RedisQueryResults
22+
23+
24+
class TestRedisQueryResults:
25+
def test_interface_matches(self):
26+
assert verifyClass(IQueryResultsCache, RedisQueryResults)
27+
28+
def test_create_service(self):
29+
request = pretend.stub(
30+
registry=pretend.stub(settings={"db_results_cache.url": "redis://"})
31+
)
32+
# Create the service
33+
service = RedisQueryResults.create_service(None, request)
34+
35+
assert isinstance(service, RedisQueryResults)
36+
37+
def test_set_get_simple(self, query_results_cache_service):
38+
# Set a value in the cache
39+
query_results_cache_service.set("test_key", {"foo": "bar"})
40+
41+
# Get the value from the cache
42+
result = query_results_cache_service.get("test_key")
43+
44+
assert result == {"foo": "bar"}
45+
46+
def test_set_get_complex(self, query_results_cache_service):
47+
# Construct a complex object to store in the cache
48+
obj = {
49+
"uuid": uuid.uuid4(),
50+
"datetime": datetime.datetime.now(),
51+
"list": [1, 2, 3],
52+
"dict": {"key": "value"},
53+
}
54+
# Set the complex object in the cache
55+
query_results_cache_service.set("complex_key", obj)
56+
57+
# Get the complex object from the cache
58+
result = query_results_cache_service.get("complex_key")
59+
60+
# Check that the result is the "same" as the original object, except
61+
# for the UUID and datetime, which are now strings
62+
assert result["list"] == obj["list"]
63+
assert result["dict"] == obj["dict"]
64+
assert result["uuid"] == str(obj["uuid"])
65+
assert result["datetime"] == obj["datetime"].isoformat()
66+
assert isinstance(result["list"], list)
67+
assert isinstance(result["dict"], dict)
68+
assert isinstance(result["uuid"], str)
69+
assert isinstance(result["datetime"], str)

tests/unit/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ def __init__(self):
426426
pretend.call(".sessions"),
427427
pretend.call(".cache.http"),
428428
pretend.call(".cache.origin"),
429+
pretend.call(".cache"),
429430
pretend.call(".email"),
430431
pretend.call(".accounts"),
431432
pretend.call(".macaroons"),

warehouse/cache/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,19 @@
99
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
12+
13+
from __future__ import annotations
14+
15+
import typing
16+
17+
from .interfaces import IQueryResultsCache
18+
from .services import RedisQueryResults
19+
20+
if typing.TYPE_CHECKING:
21+
from pyramid.config import Configurator
22+
23+
24+
def includeme(config: Configurator) -> None:
25+
config.register_service_factory(
26+
RedisQueryResults.create_service, IQueryResultsCache
27+
)

warehouse/cache/interfaces.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
from zope.interface import Interface
14+
15+
16+
class IQueryResultsCache(Interface):
17+
"""
18+
A cache for expensive/slow database query results.
19+
20+
Example usage:
21+
22+
>>> some_expensive_query = request.db.query(...)
23+
>>> cache_service = request.find_service(IQueryResultsCache)
24+
>>> cache_service.set("some_key_name", some_expensive_query)
25+
26+
# Later, retrieve the cached results:
27+
>>> results = cache_service.get("some_key_name")
28+
"""
29+
30+
def create_service(context, request):
31+
"""Create the service, bootstrap any configuration needed."""
32+
33+
def get(key: str):
34+
"""Get a cached result by key."""
35+
36+
def set(key: str, value):
37+
"""Set a cached result by key."""
38+
# TODO: do we need a set-with-expiration, a la `setex`?

warehouse/cache/services.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
from __future__ import annotations
14+
15+
import typing
16+
17+
import orjson
18+
import redis
19+
20+
from zope.interface import implementer
21+
22+
from warehouse.cache.interfaces import IQueryResultsCache
23+
24+
if typing.TYPE_CHECKING:
25+
from pyramid.request import Request
26+
27+
28+
@implementer(IQueryResultsCache)
29+
class RedisQueryResults:
30+
"""
31+
A Redis-based query results cache.
32+
33+
Anything using this service must assume that the key results may be empty,
34+
and handle the case where the key is not found in the cache.
35+
36+
The key is a string, and the value is a JSON-serialized object as a string.
37+
"""
38+
39+
def __init__(self, redis_client):
40+
self.redis_client = redis_client
41+
42+
@classmethod
43+
def create_service(cls, _context, request: Request) -> RedisQueryResults:
44+
redis_url = request.registry.settings["db_results_cache.url"]
45+
redis_client = redis.StrictRedis.from_url(redis_url)
46+
return cls(redis_client)
47+
48+
def get(self, key: str) -> list | dict | None:
49+
"""Get a cached result by key."""
50+
result = self.redis_client.get(key)
51+
# deserialize the value as a JSON object
52+
return orjson.loads(result)
53+
54+
def set(self, key: str, value) -> None:
55+
"""Set a cached result by key."""
56+
# serialize the value as a JSON string
57+
value = orjson.dumps(value)
58+
self.redis_client.set(key, value)

warehouse/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ def configure(settings=None):
388388
maybe_set(settings, "sentry.transport", "SENTRY_TRANSPORT")
389389
maybe_set_redis(settings, "sessions.url", "REDIS_URL", db=2)
390390
maybe_set_redis(settings, "ratelimit.url", "REDIS_URL", db=3)
391+
maybe_set_redis(settings, "db_results_cache.url", "REDIS_URL", db=5)
391392
maybe_set(settings, "captcha.backend", "CAPTCHA_BACKEND")
392393
maybe_set(settings, "recaptcha.site_key", "RECAPTCHA_SITE_KEY")
393394
maybe_set(settings, "recaptcha.secret_key", "RECAPTCHA_SECRET_KEY")
@@ -810,6 +811,8 @@ def configure(settings=None):
810811
# Register our support for http and origin caching
811812
config.include(".cache.http")
812813
config.include(".cache.origin")
814+
# Register our support for the database results cache
815+
config.include(".cache")
813816

814817
# Register support for sending emails
815818
config.include(".email")

0 commit comments

Comments
 (0)