Merge ~cjwatson/launchpad:refactor-pgsession-queries into launchpad:master

Proposed by Colin Watson
Status: Needs review
Proposed branch: ~cjwatson/launchpad:refactor-pgsession-queries
Merge into: launchpad:master
Diff against target: 329 lines (+116/-88)
4 files modified
lib/lp/services/session/adapters.py (+2/-1)
lib/lp/services/session/model.py (+75/-3)
lib/lp/services/webapp/pgsession.py (+38/-83)
tox.ini (+1/-1)
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+437794@code.launchpad.net

Commit message

Refactor PGSession queries to reduce manual SQL

Description of the change

Pushing the special handling for old session pickles saved by Python 2 down to the `SessionPkgData` model and adding some named function declarations allows us to make quite effective use of Storm's query compiler, eliminating some manually-constructed SQL and giving us better type annotations.

It's possible that at least the dump compatibility with Python 2 can now be removed, but I deliberately haven't attempted that in this commit: this should be a pure refactoring with no functional change.

To post a comment you must log in.

Unmerged commits

1fe67cd... by Colin Watson

Refactor PGSession queries to reduce manual SQL

Pushing the special handling for old session pickles saved by Python 2
down to the `SessionPkgData` model and adding some named function
declarations allows us to make quite effective use of Storm's query
compiler, eliminating some manually-constructed SQL and giving us better
type annotations.

It's possible that at least the dump compatibility with Python 2 can now
be removed, but I deliberately haven't attempted that in this commit:
this should be a pure refactoring with no functional change.

Succeeded
[SUCCEEDED] docs:0 (build)
[SUCCEEDED] lint:0 (build)
[SUCCEEDED] mypy:0 (build)
13 of 3 results

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/services/session/adapters.py b/lib/lp/services/session/adapters.py
index f40145c..ae1e342 100644
--- a/lib/lp/services/session/adapters.py
+++ b/lib/lp/services/session/adapters.py
@@ -3,8 +3,9 @@
33
4"""Session adapters."""4"""Session adapters."""
55
6__all__ = []6__all__ = [] # type: List[str]
77
8from typing import List
89
9from zope.component import adapter10from zope.component import adapter
10from zope.interface import implementer11from zope.interface import implementer
diff --git a/lib/lp/services/session/model.py b/lib/lp/services/session/model.py
index b54d290..bd83e96 100644
--- a/lib/lp/services/session/model.py
+++ b/lib/lp/services/session/model.py
@@ -3,9 +3,21 @@
33
4"""Session Storm database classes"""4"""Session Storm database classes"""
55
6__all__ = ["SessionData", "SessionPkgData"]6__all__ = [
7 "EnsureSessionClientID",
8 "SessionData",
9 "SessionPkgData",
10 "SetSessionPkgData",
11]
712
8from storm.locals import Pickle, Unicode13import io
14import pickle
15from datetime import datetime
16from typing import Any
17
18from storm.expr import NamedFunc
19from storm.properties import SimpleProperty, Unicode
20from storm.variables import PickleVariable
9from zope.interface import implementer, provider21from zope.interface import implementer, provider
1022
11from lp.services.database.datetimecol import UtcDateTimeCol23from lp.services.database.datetimecol import UtcDateTimeCol
@@ -24,6 +36,46 @@ class SessionData(StormBase):
24 last_accessed = UtcDateTimeCol()36 last_accessed = UtcDateTimeCol()
2537
2638
39class Python2FriendlyUnpickler(pickle._Unpickler):
40 """An unpickler that handles Python 2 datetime objects.
41
42 Python 3 versions before 3.6 fail to unpickle Python 2 datetime objects
43 (https://bugs.python.org/issue22005); even in Python >= 3.6 they require
44 passing a different encoding to pickle.loads, which may have undesirable
45 effects on other objects being unpickled. Work around this by instead
46 patching in a different encoding just for the argument to
47 datetime.datetime.
48 """
49
50 def find_class(self, module: str, name: str) -> Any:
51 if module == "datetime" and name == "datetime":
52 original_encoding = self.encoding # type: ignore[has-type]
53 self.encoding = "bytes"
54
55 def datetime_factory(pickle_data):
56 self.encoding = original_encoding
57 return datetime(pickle_data)
58
59 return datetime_factory
60 else:
61 return super().find_class(module, name)
62
63
64class Python2FriendlyPickleVariable(PickleVariable):
65 def _loads(self, value: bytes) -> object:
66 return Python2FriendlyUnpickler(io.BytesIO(value)).load()
67
68
69class Python2FriendlyPickle(SimpleProperty):
70 """Like `storm.properties.Pickle`, but also handles data from Python 2.
71
72 We need this even though Launchpad no longer runs on Python 2, because
73 the session database may contain old data.
74 """
75
76 variable_class = Python2FriendlyPickleVariable
77
78
27@implementer(IUseSessionStore)79@implementer(IUseSessionStore)
28@provider(IUseSessionStore)80@provider(IUseSessionStore)
29class SessionPkgData(StormBase):81class SessionPkgData(StormBase):
@@ -35,4 +87,24 @@ class SessionPkgData(StormBase):
35 client_id = Unicode()87 client_id = Unicode()
36 product_id = Unicode()88 product_id = Unicode()
37 key = Unicode()89 key = Unicode()
38 pickle = Pickle()90 pickle = Python2FriendlyPickle()
91
92
93class EnsureSessionClientID(NamedFunc):
94 __slots__ = ()
95 name = "ensure_session_client_id"
96
97 def __init__(self, hashed_client_id: str) -> None:
98 super().__init__(hashed_client_id)
99
100
101class SetSessionPkgData(NamedFunc):
102 __slots__ = ()
103 name = "set_session_pkg_data"
104
105 def __init__(
106 self, hashed_client_id: str, product_id: str, key: str, value: object
107 ) -> None:
108 # Use protocol 2 for Python 2 compatibility.
109 pickled_value = pickle.dumps(value, protocol=2)
110 super().__init__(hashed_client_id, product_id, key, pickled_value)
diff --git a/lib/lp/services/webapp/pgsession.py b/lib/lp/services/webapp/pgsession.py
index 1f25642..5a0fa5d 100644
--- a/lib/lp/services/webapp/pgsession.py
+++ b/lib/lp/services/webapp/pgsession.py
@@ -4,18 +4,24 @@
4"""PostgreSQL server side session storage for Zope3."""4"""PostgreSQL server side session storage for Zope3."""
55
6import hashlib6import hashlib
7import io
8import pickle
9from collections.abc import MutableMapping7from collections.abc import MutableMapping
10from datetime import datetime8from datetime import timedelta
119
12import six10import six
13from lazr.restful.utils import get_current_browser_request11from lazr.restful.utils import get_current_browser_request
12from storm.expr import Cast, Select
14from storm.zope.interfaces import IZStorm13from storm.zope.interfaces import IZStorm
15from zope.authentication.interfaces import IUnauthenticatedPrincipal14from zope.authentication.interfaces import IUnauthenticatedPrincipal
16from zope.component import getUtility15from zope.component import getUtility
17from zope.interface import implementer16from zope.interface import implementer
1817
18from lp.services.database.constants import UTC_NOW
19from lp.services.session.model import (
20 EnsureSessionClientID,
21 SessionData,
22 SessionPkgData,
23 SetSessionPkgData,
24)
19from lp.services.webapp.interfaces import (25from lp.services.webapp.interfaces import (
20 IClientIdManager,26 IClientIdManager,
21 ISessionData,27 ISessionData,
@@ -29,31 +35,6 @@ HOURS = 60 * MINUTES
29DAYS = 24 * HOURS35DAYS = 24 * HOURS
3036
3137
32class Python2FriendlyUnpickler(pickle._Unpickler):
33 """An unpickler that handles Python 2 datetime objects.
34
35 Python 3 versions before 3.6 fail to unpickle Python 2 datetime objects
36 (https://bugs.python.org/issue22005); even in Python >= 3.6 they require
37 passing a different encoding to pickle.loads, which may have undesirable
38 effects on other objects being unpickled. Work around this by instead
39 patching in a different encoding just for the argument to
40 datetime.datetime.
41 """
42
43 def find_class(self, module, name):
44 if module == "datetime" and name == "datetime":
45 original_encoding = self.encoding
46 self.encoding = "bytes"
47
48 def datetime_factory(pickle_data):
49 self.encoding = original_encoding
50 return datetime(pickle_data)
51
52 return datetime_factory
53 else:
54 return super().find_class(module, name)
55
56
57class PGSessionBase:38class PGSessionBase:
58 store_name = "session"39 store_name = "session"
5940
@@ -90,9 +71,6 @@ class PGSessionDataContainer(PGSessionBase):
90 # using the session data.71 # using the session data.
91 resolution = 9 * MINUTES72 resolution = 9 * MINUTES
9273
93 session_data_table_name = "SessionData"
94 session_pkg_data_table_name = "SessionPkgData"
95
96 def __getitem__(self, client_id):74 def __getitem__(self, client_id):
97 """See `ISessionDataContainer`."""75 """See `ISessionDataContainer`."""
98 return PGSessionData(self, client_id)76 return PGSessionData(self, client_id)
@@ -119,16 +97,16 @@ class PGSessionData(PGSessionBase):
119 ).hexdigest()97 ).hexdigest()
12098
121 # Update the last access time in the db if it is out of date99 # Update the last access time in the db if it is out of date
122 table_name = session_data_container.session_data_table_name100 self.store.find(
123 query = """101 SessionData,
124 UPDATE %s SET last_accessed = CURRENT_TIMESTAMP102 SessionData.client_id == self.hashed_client_id,
125 WHERE client_id = ?103 SessionData.last_accessed
126 AND last_accessed < CURRENT_TIMESTAMP - '%d seconds'::interval104 < UTC_NOW
127 """ % (105 - Cast(
128 table_name,106 timedelta(seconds=session_data_container.resolution),
129 session_data_container.resolution,107 "interval",
130 )108 ),
131 self.store.execute(query, (self.hashed_client_id,), noresult=True)109 ).set(last_accessed=UTC_NOW)
132110
133 def _ensureClientId(self):111 def _ensureClientId(self):
134 if self._have_ensured_client_id:112 if self._have_ensured_client_id:
@@ -137,8 +115,7 @@ class PGSessionData(PGSessionBase):
137 # about our client id. We're doing it lazily to try and keep anonymous115 # about our client id. We're doing it lazily to try and keep anonymous
138 # users from having a session.116 # users from having a session.
139 self.store.execute(117 self.store.execute(
140 "SELECT ensure_session_client_id(?)",118 Select(EnsureSessionClientID(self.hashed_client_id)),
141 (self.hashed_client_id,),
142 noresult=True,119 noresult=True,
143 )120 )
144 request = get_current_browser_request()121 request = get_current_browser_request()
@@ -198,29 +175,18 @@ class PGSessionPkgData(MutableMapping, PGSessionBase):
198 def __init__(self, session_data, product_id):175 def __init__(self, session_data, product_id):
199 self.session_data = session_data176 self.session_data = session_data
200 self.product_id = six.ensure_text(product_id, "ascii")177 self.product_id = six.ensure_text(product_id, "ascii")
201 self.table_name = (
202 session_data.session_data_container.session_pkg_data_table_name
203 )
204 self._populate()178 self._populate()
205179
206 _data_cache = None180 _data_cache = None
207181
208 def _populate(self):182 def _populate(self):
209 self._data_cache = {}183 self._data_cache = {}
210 query = (184 result = self.store.find(
211 """185 (SessionPkgData.key, SessionPkgData.pickle),
212 SELECT key, pickle FROM %s WHERE client_id = ?186 SessionPkgData.client_id == self.session_data.hashed_client_id,
213 AND product_id = ?187 SessionPkgData.product_id == self.product_id,
214 """
215 % self.table_name
216 )
217 result = self.store.execute(
218 query, (self.session_data.hashed_client_id, self.product_id)
219 )188 )
220 for key, pickled_value in result:189 for key, value in result:
221 value = Python2FriendlyUnpickler(
222 io.BytesIO(bytes(pickled_value))
223 ).load()
224 self._data_cache[key] = value190 self._data_cache[key] = value
225191
226 def __getitem__(self, key):192 def __getitem__(self, key):
@@ -228,17 +194,16 @@ class PGSessionPkgData(MutableMapping, PGSessionBase):
228194
229 def __setitem__(self, key, value):195 def __setitem__(self, key, value):
230 key = six.ensure_text(key, "ascii")196 key = six.ensure_text(key, "ascii")
231 # Use protocol 2 for Python 2 compatibility.
232 pickled_value = pickle.dumps(value, protocol=2)
233197
234 self.session_data._ensureClientId()198 self.session_data._ensureClientId()
235 self.store.execute(199 self.store.execute(
236 "SELECT set_session_pkg_data(?, ?, ?, ?)",200 Select(
237 (201 SetSessionPkgData(
238 self.session_data.hashed_client_id,202 self.session_data.hashed_client_id,
239 self.product_id,203 self.product_id,
240 key,204 key,
241 pickled_value,205 value,
206 )
242 ),207 ),
243 noresult=True,208 noresult=True,
244 )209 )
@@ -259,22 +224,12 @@ class PGSessionPkgData(MutableMapping, PGSessionBase):
259 # another process has inserted it and we should keep our grubby224 # another process has inserted it and we should keep our grubby
260 # fingers out of it.225 # fingers out of it.
261 return226 return
262 query = (227 self.store.find(
263 """228 SessionPkgData,
264 DELETE FROM %s229 SessionPkgData.client_id == self.session_data.hashed_client_id,
265 WHERE client_id = ? AND product_id = ? AND key = ?230 SessionPkgData.product_id == self.product_id,
266 """231 SessionPkgData.key == six.ensure_text(key, "ascii"),
267 % self.table_name232 ).remove()
268 )
269 self.store.execute(
270 query,
271 (
272 self.session_data.hashed_client_id,
273 self.product_id,
274 six.ensure_text(key, "ascii"),
275 ),
276 noresult=True,
277 )
278233
279 def __iter__(self):234 def __iter__(self):
280 return iter(self._data_cache)235 return iter(self._data_cache)
diff --git a/tox.ini b/tox.ini
index 9054ea2..9319722 100644
--- a/tox.ini
+++ b/tox.ini
@@ -27,7 +27,7 @@ commands_pre =
27 {toxinidir}/scripts/update-version-info.sh27 {toxinidir}/scripts/update-version-info.sh
28commands =28commands =
29 mypy --follow-imports=silent \29 mypy --follow-imports=silent \
30 {posargs:lib/lp/answers lib/lp/app lib/lp/archivepublisher lib/lp/archiveuploader lib/lp/buildmaster lib/lp/charms/model/charmrecipebuildbehaviour.py lib/lp/code/model/cibuildbehaviour.py lib/lp/code/model/recipebuilder.py lib/lp/oci/model/ocirecipebuildbehaviour.py lib/lp/snappy/model/snapbuildbehaviour.py lib/lp/soyuz/model/binarypackagebuildbehaviour.py lib/lp/soyuz/model/livefsbuildbehaviour.py lib/lp/testing lib/lp/translations/model/translationtemplatesbuildbehaviour.py}30 {posargs:lib/lp/answers lib/lp/app lib/lp/archivepublisher lib/lp/archiveuploader lib/lp/buildmaster lib/lp/charms/model/charmrecipebuildbehaviour.py lib/lp/code/model/cibuildbehaviour.py lib/lp/code/model/recipebuilder.py lib/lp/oci/model/ocirecipebuildbehaviour.py lib/lp/services/session lib/lp/services/webapp/pgsession.py lib/lp/snappy/model/snapbuildbehaviour.py lib/lp/soyuz/model/binarypackagebuildbehaviour.py lib/lp/soyuz/model/livefsbuildbehaviour.py lib/lp/testing lib/lp/translations/model/translationtemplatesbuildbehaviour.py}
3131
32[testenv:docs]32[testenv:docs]
33basepython = python333basepython = python3

Subscribers

People subscribed via source and target branches

to status/vote changes: