Skip to content

Commit b378e8e

Browse files
authored
User cache purge (#3189)
* Generate cache keys via a key_factory * Add cache key generation from iterable attribute * Purge user when projects or releases change * Purge user's projects when purging user This also makes it impossible to use the `key_factory` and `iterate_on` with cache keys, to avoid the problem of unbounded surrogate keys. * Delete projects when nuking user with query We don't need to do this per-project to purge the cache anymore.
1 parent 744cfbd commit b378e8e

File tree

6 files changed

+120
-37
lines changed

6 files changed

+120
-37
lines changed

tests/unit/admin/views/test_users.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from webob.multidict import MultiDict
1919

2020
from warehouse.admin.views import users as views
21+
from warehouse.packaging.models import Project
2122

2223
from ....common.db.accounts import User, UserFactory, EmailFactory
2324
from ....common.db.packaging import (
@@ -178,6 +179,7 @@ class TestUserDelete:
178179
def test_deletes_user(self, db_request, monkeypatch):
179180
user = UserFactory.create()
180181
project = ProjectFactory.create()
182+
another_project = ProjectFactory.create()
181183
journal = JournalEntryFactory(submitted_by=user)
182184
RoleFactory(project=project, user=user, role_name='Owner')
183185
deleted_user = UserFactory.create(username="deleted-user")
@@ -188,17 +190,12 @@ def test_deletes_user(self, db_request, monkeypatch):
188190
db_request.user = UserFactory.create()
189191
db_request.remote_addr = '10.10.10.10'
190192

191-
remove_project = pretend.call_recorder(lambda *a, **kw: None)
192-
monkeypatch.setattr(views, 'remove_project', remove_project)
193-
194193
result = views.user_delete(db_request)
195194

196195
db_request.db.flush()
197196

198197
assert not db_request.db.query(User).get(user.id)
199-
assert remove_project.calls == [
200-
pretend.call(project, db_request, flash=False),
201-
]
198+
assert db_request.db.query(Project).all() == [another_project]
202199
assert db_request.route_path.calls == [pretend.call('admin.user.list')]
203200
assert result.status_code == 303
204201
assert result.location == '/foobar'
@@ -213,15 +210,12 @@ def test_deletes_user_bad_confirm(self, db_request, monkeypatch):
213210
db_request.params = {'username': 'wrong'}
214211
db_request.route_path = pretend.call_recorder(lambda a, **k: '/foobar')
215212

216-
remove_project = pretend.call_recorder(lambda *a, **kw: None)
217-
monkeypatch.setattr(views, 'remove_project', remove_project)
218-
219213
result = views.user_delete(db_request)
220214

221215
db_request.db.flush()
222216

223217
assert db_request.db.query(User).get(user.id)
224-
assert remove_project.calls == []
218+
assert db_request.db.query(Project).all() == [project]
225219
assert db_request.route_path.calls == [
226220
pretend.call('admin.user.detail', user_id=user.id),
227221
]

tests/unit/cache/origin/test_init.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,32 +204,55 @@ class TestKeyMaker:
204204
def test_both_cache_and_purge(self):
205205
key_maker = origin.key_maker_factory(
206206
cache_keys=["foo", "foo/{obj.attr}"],
207-
purge_keys=["bar", "bar/{obj.attr}"],
208-
)
209-
assert key_maker(pretend.stub(attr="bar")) == origin.CacheKeys(
210-
cache=["foo", "foo/bar"],
211-
purge=["bar", "bar/bar"],
207+
purge_keys=[
208+
origin.key_factory("bar"),
209+
origin.key_factory("bar/{obj.attr}"),
210+
],
212211
)
212+
cache_keys = key_maker(pretend.stub(attr="bar"))
213+
214+
assert isinstance(cache_keys, origin.CacheKeys)
215+
assert cache_keys.cache == ["foo", "foo/bar"]
216+
assert list(cache_keys.purge) == ["bar", "bar/bar"]
213217

214218
def test_only_cache(self):
215219
key_maker = origin.key_maker_factory(
216220
cache_keys=["foo", "foo/{obj.attr}"],
217221
purge_keys=None,
218222
)
219-
assert key_maker(pretend.stub(attr="bar")) == origin.CacheKeys(
220-
cache=["foo", "foo/bar"],
221-
purge=[],
222-
)
223+
cache_keys = key_maker(pretend.stub(attr="bar"))
224+
225+
assert isinstance(cache_keys, origin.CacheKeys)
226+
assert cache_keys.cache == ["foo", "foo/bar"]
227+
assert list(cache_keys.purge) == []
223228

224229
def test_only_purge(self):
225230
key_maker = origin.key_maker_factory(
226231
cache_keys=None,
227-
purge_keys=["bar", "bar/{obj.attr}"],
232+
purge_keys=[
233+
origin.key_factory("bar"),
234+
origin.key_factory("bar/{obj.attr}"),
235+
],
228236
)
229-
assert key_maker(pretend.stub(attr="bar")) == origin.CacheKeys(
230-
cache=[],
231-
purge=["bar", "bar/bar"],
237+
cache_keys = key_maker(pretend.stub(attr="bar"))
238+
239+
assert isinstance(cache_keys, origin.CacheKeys)
240+
assert cache_keys.cache == []
241+
assert list(cache_keys.purge) == ["bar", "bar/bar"]
242+
243+
def test_iterate_on(self):
244+
key_maker = origin.key_maker_factory(
245+
cache_keys=['foo'], # Intentionally does not support `iterate_on`
246+
purge_keys=[
247+
origin.key_factory('bar'),
248+
origin.key_factory('bar/{itr}', iterate_on='iterate_me'),
249+
],
232250
)
251+
cache_keys = key_maker(pretend.stub(iterate_me=['biz', 'baz']))
252+
253+
assert isinstance(cache_keys, origin.CacheKeys)
254+
assert cache_keys.cache == ['foo']
255+
assert list(cache_keys.purge) == ['bar', 'bar/biz', 'bar/baz']
233256

234257

235258
def test_register_origin_keys(monkeypatch):

tests/unit/packaging/test_init.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from warehouse import packaging
1919
from warehouse.packaging.interfaces import IDownloadStatService, IFileStorage
20-
from warehouse.packaging.models import Project, Release
20+
from warehouse.packaging.models import Project, Release, User
2121
from warehouse.packaging.tasks import compute_trending
2222

2323

@@ -33,6 +33,11 @@ def test_includme(monkeypatch, with_trending):
3333
packaging, "RedisDownloadStatService", download_stat_service_cls,
3434
)
3535

36+
def key_factory(keystring, iterate_on=None):
37+
return pretend.call(keystring, iterate_on=iterate_on)
38+
39+
monkeypatch.setattr(packaging, 'key_factory', key_factory)
40+
3641
config = pretend.stub(
3742
maybe_dotted=lambda dotted: storage_class,
3843
register_service=pretend.call_recorder(
@@ -72,14 +77,30 @@ def test_includme(monkeypatch, with_trending):
7277
pretend.call(
7378
Project,
7479
cache_keys=["project/{obj.normalized_name}"],
75-
purge_keys=["project/{obj.normalized_name}", "all-projects"],
80+
purge_keys=[
81+
key_factory("project/{obj.normalized_name}"),
82+
key_factory("user/{itr.username}", iterate_on='users'),
83+
key_factory("all-projects"),
84+
],
7685
),
7786
pretend.call(
7887
Release,
7988
cache_keys=["project/{obj.project.normalized_name}"],
8089
purge_keys=[
81-
"project/{obj.project.normalized_name}",
82-
"all-projects",
90+
key_factory("project/{obj.project.normalized_name}"),
91+
key_factory("user/{itr.username}", iterate_on='project.users'),
92+
key_factory("all-projects"),
93+
],
94+
),
95+
pretend.call(
96+
User,
97+
cache_keys=["user/{obj.username}"],
98+
purge_keys=[
99+
key_factory("user/{obj.username}"),
100+
key_factory(
101+
"project/{itr.normalized_name}",
102+
iterate_on='projects',
103+
),
83104
],
84105
),
85106
]

warehouse/admin/views/users.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@
2323

2424
from warehouse import forms
2525
from warehouse.accounts.models import User, Email
26-
from warehouse.packaging.models import JournalEntry, Role
26+
from warehouse.packaging.models import JournalEntry, Project, Role
2727
from warehouse.utils.paginate import paginate_url_factory
28-
from warehouse.utils.project import remove_project
2928

3029

3130
@view_config(
@@ -139,16 +138,24 @@ def user_delete(request):
139138
user = request.db.query(User).get(request.matchdict['user_id'])
140139

141140
if user.username != request.params.get('username'):
142-
print(user.username)
143-
print(request.params.get('username'))
144141
request.session.flash(f'Wrong confirmation input.', queue='error')
145142
return HTTPSeeOther(
146143
request.route_path('admin.user.detail', user_id=user.id)
147144
)
148145

149-
# Delete projects one by one so they are purged from the cache
150-
for project in user.projects:
151-
remove_project(project, request, flash=False)
146+
# Delete all the user's projects
147+
projects = (
148+
request.db.query(Project)
149+
.filter(
150+
Project.name.in_(
151+
request.db.query(Project.name)
152+
.join(Role.project)
153+
.filter(Role.user == user)
154+
.subquery()
155+
)
156+
)
157+
)
158+
projects.delete(synchronize_session=False)
152159

153160
# Update all journals to point to `deleted-user` instead
154161
deleted_user = (

warehouse/cache/origin/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
import collections
1414
import functools
15+
import operator
16+
from itertools import chain
1517

1618
from warehouse import db
1719
from warehouse.cache.origin.interfaces import IOriginCache
@@ -87,6 +89,18 @@ def wrapped(context, request):
8789
CacheKeys = collections.namedtuple("CacheKeys", ["cache", "purge"])
8890

8991

92+
def key_factory(keystring, iterate_on=None):
93+
94+
def generate_key(obj):
95+
if iterate_on:
96+
for itr in operator.attrgetter(iterate_on)(obj):
97+
yield keystring.format(itr=itr, obj=obj)
98+
else:
99+
yield keystring.format(obj=obj)
100+
101+
return generate_key
102+
103+
90104
def key_maker_factory(cache_keys, purge_keys):
91105
if cache_keys is None:
92106
cache_keys = []
@@ -96,8 +110,14 @@ def key_maker_factory(cache_keys, purge_keys):
96110

97111
def key_maker(obj):
98112
return CacheKeys(
113+
# Note: this does not support setting the `cache` argument via
114+
# multiple `key_factories` as we do with `purge` because there is
115+
# a limit to how many surrogate keys we can attach to a single HTTP
116+
# response, and being able to use use `iterate_on` would allow this
117+
# size to be unbounded.
118+
# ref: https://github.com/pypa/warehouse/pull/3189
99119
cache=[k.format(obj=obj) for k in cache_keys],
100-
purge=[k.format(obj=obj) for k in purge_keys],
120+
purge=chain.from_iterable(key(obj) for key in purge_keys),
101121
)
102122

103123
return key_maker

warehouse/packaging/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from celery.schedules import crontab
1414

15+
from warehouse.accounts.models import User
16+
from warehouse.cache.origin import key_factory
1517
from warehouse.packaging.interfaces import IDownloadStatService, IFileStorage
1618
from warehouse.packaging.services import RedisDownloadStatService
1719
from warehouse.packaging.models import Project, Release
@@ -39,12 +41,28 @@ def includeme(config):
3941
config.register_origin_cache_keys(
4042
Project,
4143
cache_keys=["project/{obj.normalized_name}"],
42-
purge_keys=["project/{obj.normalized_name}", "all-projects"],
44+
purge_keys=[
45+
key_factory("project/{obj.normalized_name}"),
46+
key_factory("user/{itr.username}", iterate_on='users'),
47+
key_factory("all-projects"),
48+
],
4349
)
4450
config.register_origin_cache_keys(
4551
Release,
4652
cache_keys=["project/{obj.project.normalized_name}"],
47-
purge_keys=["project/{obj.project.normalized_name}", "all-projects"],
53+
purge_keys=[
54+
key_factory("project/{obj.project.normalized_name}"),
55+
key_factory("user/{itr.username}", iterate_on='project.users'),
56+
key_factory("all-projects"),
57+
],
58+
)
59+
config.register_origin_cache_keys(
60+
User,
61+
cache_keys=["user/{obj.username}"],
62+
purge_keys=[
63+
key_factory("user/{obj.username}"),
64+
key_factory("project/{itr.normalized_name}", iterate_on='projects')
65+
],
4866
)
4967

5068
# Add a periodic task to compute trending once a day, assuming we have

0 commit comments

Comments
 (0)