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
1diff --git a/lib/lp/services/session/adapters.py b/lib/lp/services/session/adapters.py
2index f40145c..ae1e342 100644
3--- a/lib/lp/services/session/adapters.py
4+++ b/lib/lp/services/session/adapters.py
5@@ -3,8 +3,9 @@
6
7 """Session adapters."""
8
9-__all__ = []
10+__all__ = [] # type: List[str]
11
12+from typing import List
13
14 from zope.component import adapter
15 from zope.interface import implementer
16diff --git a/lib/lp/services/session/model.py b/lib/lp/services/session/model.py
17index b54d290..bd83e96 100644
18--- a/lib/lp/services/session/model.py
19+++ b/lib/lp/services/session/model.py
20@@ -3,9 +3,21 @@
21
22 """Session Storm database classes"""
23
24-__all__ = ["SessionData", "SessionPkgData"]
25+__all__ = [
26+ "EnsureSessionClientID",
27+ "SessionData",
28+ "SessionPkgData",
29+ "SetSessionPkgData",
30+]
31
32-from storm.locals import Pickle, Unicode
33+import io
34+import pickle
35+from datetime import datetime
36+from typing import Any
37+
38+from storm.expr import NamedFunc
39+from storm.properties import SimpleProperty, Unicode
40+from storm.variables import PickleVariable
41 from zope.interface import implementer, provider
42
43 from lp.services.database.datetimecol import UtcDateTimeCol
44@@ -24,6 +36,46 @@ class SessionData(StormBase):
45 last_accessed = UtcDateTimeCol()
46
47
48+class Python2FriendlyUnpickler(pickle._Unpickler):
49+ """An unpickler that handles Python 2 datetime objects.
50+
51+ Python 3 versions before 3.6 fail to unpickle Python 2 datetime objects
52+ (https://bugs.python.org/issue22005); even in Python >= 3.6 they require
53+ passing a different encoding to pickle.loads, which may have undesirable
54+ effects on other objects being unpickled. Work around this by instead
55+ patching in a different encoding just for the argument to
56+ datetime.datetime.
57+ """
58+
59+ def find_class(self, module: str, name: str) -> Any:
60+ if module == "datetime" and name == "datetime":
61+ original_encoding = self.encoding # type: ignore[has-type]
62+ self.encoding = "bytes"
63+
64+ def datetime_factory(pickle_data):
65+ self.encoding = original_encoding
66+ return datetime(pickle_data)
67+
68+ return datetime_factory
69+ else:
70+ return super().find_class(module, name)
71+
72+
73+class Python2FriendlyPickleVariable(PickleVariable):
74+ def _loads(self, value: bytes) -> object:
75+ return Python2FriendlyUnpickler(io.BytesIO(value)).load()
76+
77+
78+class Python2FriendlyPickle(SimpleProperty):
79+ """Like `storm.properties.Pickle`, but also handles data from Python 2.
80+
81+ We need this even though Launchpad no longer runs on Python 2, because
82+ the session database may contain old data.
83+ """
84+
85+ variable_class = Python2FriendlyPickleVariable
86+
87+
88 @implementer(IUseSessionStore)
89 @provider(IUseSessionStore)
90 class SessionPkgData(StormBase):
91@@ -35,4 +87,24 @@ class SessionPkgData(StormBase):
92 client_id = Unicode()
93 product_id = Unicode()
94 key = Unicode()
95- pickle = Pickle()
96+ pickle = Python2FriendlyPickle()
97+
98+
99+class EnsureSessionClientID(NamedFunc):
100+ __slots__ = ()
101+ name = "ensure_session_client_id"
102+
103+ def __init__(self, hashed_client_id: str) -> None:
104+ super().__init__(hashed_client_id)
105+
106+
107+class SetSessionPkgData(NamedFunc):
108+ __slots__ = ()
109+ name = "set_session_pkg_data"
110+
111+ def __init__(
112+ self, hashed_client_id: str, product_id: str, key: str, value: object
113+ ) -> None:
114+ # Use protocol 2 for Python 2 compatibility.
115+ pickled_value = pickle.dumps(value, protocol=2)
116+ super().__init__(hashed_client_id, product_id, key, pickled_value)
117diff --git a/lib/lp/services/webapp/pgsession.py b/lib/lp/services/webapp/pgsession.py
118index 1f25642..5a0fa5d 100644
119--- a/lib/lp/services/webapp/pgsession.py
120+++ b/lib/lp/services/webapp/pgsession.py
121@@ -4,18 +4,24 @@
122 """PostgreSQL server side session storage for Zope3."""
123
124 import hashlib
125-import io
126-import pickle
127 from collections.abc import MutableMapping
128-from datetime import datetime
129+from datetime import timedelta
130
131 import six
132 from lazr.restful.utils import get_current_browser_request
133+from storm.expr import Cast, Select
134 from storm.zope.interfaces import IZStorm
135 from zope.authentication.interfaces import IUnauthenticatedPrincipal
136 from zope.component import getUtility
137 from zope.interface import implementer
138
139+from lp.services.database.constants import UTC_NOW
140+from lp.services.session.model import (
141+ EnsureSessionClientID,
142+ SessionData,
143+ SessionPkgData,
144+ SetSessionPkgData,
145+)
146 from lp.services.webapp.interfaces import (
147 IClientIdManager,
148 ISessionData,
149@@ -29,31 +35,6 @@ HOURS = 60 * MINUTES
150 DAYS = 24 * HOURS
151
152
153-class Python2FriendlyUnpickler(pickle._Unpickler):
154- """An unpickler that handles Python 2 datetime objects.
155-
156- Python 3 versions before 3.6 fail to unpickle Python 2 datetime objects
157- (https://bugs.python.org/issue22005); even in Python >= 3.6 they require
158- passing a different encoding to pickle.loads, which may have undesirable
159- effects on other objects being unpickled. Work around this by instead
160- patching in a different encoding just for the argument to
161- datetime.datetime.
162- """
163-
164- def find_class(self, module, name):
165- if module == "datetime" and name == "datetime":
166- original_encoding = self.encoding
167- self.encoding = "bytes"
168-
169- def datetime_factory(pickle_data):
170- self.encoding = original_encoding
171- return datetime(pickle_data)
172-
173- return datetime_factory
174- else:
175- return super().find_class(module, name)
176-
177-
178 class PGSessionBase:
179 store_name = "session"
180
181@@ -90,9 +71,6 @@ class PGSessionDataContainer(PGSessionBase):
182 # using the session data.
183 resolution = 9 * MINUTES
184
185- session_data_table_name = "SessionData"
186- session_pkg_data_table_name = "SessionPkgData"
187-
188 def __getitem__(self, client_id):
189 """See `ISessionDataContainer`."""
190 return PGSessionData(self, client_id)
191@@ -119,16 +97,16 @@ class PGSessionData(PGSessionBase):
192 ).hexdigest()
193
194 # Update the last access time in the db if it is out of date
195- table_name = session_data_container.session_data_table_name
196- query = """
197- UPDATE %s SET last_accessed = CURRENT_TIMESTAMP
198- WHERE client_id = ?
199- AND last_accessed < CURRENT_TIMESTAMP - '%d seconds'::interval
200- """ % (
201- table_name,
202- session_data_container.resolution,
203- )
204- self.store.execute(query, (self.hashed_client_id,), noresult=True)
205+ self.store.find(
206+ SessionData,
207+ SessionData.client_id == self.hashed_client_id,
208+ SessionData.last_accessed
209+ < UTC_NOW
210+ - Cast(
211+ timedelta(seconds=session_data_container.resolution),
212+ "interval",
213+ ),
214+ ).set(last_accessed=UTC_NOW)
215
216 def _ensureClientId(self):
217 if self._have_ensured_client_id:
218@@ -137,8 +115,7 @@ class PGSessionData(PGSessionBase):
219 # about our client id. We're doing it lazily to try and keep anonymous
220 # users from having a session.
221 self.store.execute(
222- "SELECT ensure_session_client_id(?)",
223- (self.hashed_client_id,),
224+ Select(EnsureSessionClientID(self.hashed_client_id)),
225 noresult=True,
226 )
227 request = get_current_browser_request()
228@@ -198,29 +175,18 @@ class PGSessionPkgData(MutableMapping, PGSessionBase):
229 def __init__(self, session_data, product_id):
230 self.session_data = session_data
231 self.product_id = six.ensure_text(product_id, "ascii")
232- self.table_name = (
233- session_data.session_data_container.session_pkg_data_table_name
234- )
235 self._populate()
236
237 _data_cache = None
238
239 def _populate(self):
240 self._data_cache = {}
241- query = (
242- """
243- SELECT key, pickle FROM %s WHERE client_id = ?
244- AND product_id = ?
245- """
246- % self.table_name
247- )
248- result = self.store.execute(
249- query, (self.session_data.hashed_client_id, self.product_id)
250+ result = self.store.find(
251+ (SessionPkgData.key, SessionPkgData.pickle),
252+ SessionPkgData.client_id == self.session_data.hashed_client_id,
253+ SessionPkgData.product_id == self.product_id,
254 )
255- for key, pickled_value in result:
256- value = Python2FriendlyUnpickler(
257- io.BytesIO(bytes(pickled_value))
258- ).load()
259+ for key, value in result:
260 self._data_cache[key] = value
261
262 def __getitem__(self, key):
263@@ -228,17 +194,16 @@ class PGSessionPkgData(MutableMapping, PGSessionBase):
264
265 def __setitem__(self, key, value):
266 key = six.ensure_text(key, "ascii")
267- # Use protocol 2 for Python 2 compatibility.
268- pickled_value = pickle.dumps(value, protocol=2)
269
270 self.session_data._ensureClientId()
271 self.store.execute(
272- "SELECT set_session_pkg_data(?, ?, ?, ?)",
273- (
274- self.session_data.hashed_client_id,
275- self.product_id,
276- key,
277- pickled_value,
278+ Select(
279+ SetSessionPkgData(
280+ self.session_data.hashed_client_id,
281+ self.product_id,
282+ key,
283+ value,
284+ )
285 ),
286 noresult=True,
287 )
288@@ -259,22 +224,12 @@ class PGSessionPkgData(MutableMapping, PGSessionBase):
289 # another process has inserted it and we should keep our grubby
290 # fingers out of it.
291 return
292- query = (
293- """
294- DELETE FROM %s
295- WHERE client_id = ? AND product_id = ? AND key = ?
296- """
297- % self.table_name
298- )
299- self.store.execute(
300- query,
301- (
302- self.session_data.hashed_client_id,
303- self.product_id,
304- six.ensure_text(key, "ascii"),
305- ),
306- noresult=True,
307- )
308+ self.store.find(
309+ SessionPkgData,
310+ SessionPkgData.client_id == self.session_data.hashed_client_id,
311+ SessionPkgData.product_id == self.product_id,
312+ SessionPkgData.key == six.ensure_text(key, "ascii"),
313+ ).remove()
314
315 def __iter__(self):
316 return iter(self._data_cache)
317diff --git a/tox.ini b/tox.ini
318index 9054ea2..9319722 100644
319--- a/tox.ini
320+++ b/tox.ini
321@@ -27,7 +27,7 @@ commands_pre =
322 {toxinidir}/scripts/update-version-info.sh
323 commands =
324 mypy --follow-imports=silent \
325- {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}
326+ {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}
327
328 [testenv:docs]
329 basepython = python3

Subscribers

People subscribed via source and target branches

to status/vote changes: