Colin Watson has proposed merging ~cjwatson/launchpad:refactor-pgsession-queries into launchpad:master.
Commit message: Refactor PGSession queries to reduce manual SQL Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/437794 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. -- Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:refactor-pgsession-queries into launchpad:master.
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 @@ """Session adapters.""" -__all__ = [] +__all__ = [] # type: List[str] +from typing import List from zope.component import adapter from 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 @@ """Session Storm database classes""" -__all__ = ["SessionData", "SessionPkgData"] +__all__ = [ + "EnsureSessionClientID", + "SessionData", + "SessionPkgData", + "SetSessionPkgData", +] -from storm.locals import Pickle, Unicode +import io +import pickle +from datetime import datetime +from typing import Any + +from storm.expr import NamedFunc +from storm.properties import SimpleProperty, Unicode +from storm.variables import PickleVariable from zope.interface import implementer, provider from lp.services.database.datetimecol import UtcDateTimeCol @@ -24,6 +36,46 @@ class SessionData(StormBase): last_accessed = UtcDateTimeCol() +class Python2FriendlyUnpickler(pickle._Unpickler): + """An unpickler that handles Python 2 datetime objects. + + Python 3 versions before 3.6 fail to unpickle Python 2 datetime objects + (https://bugs.python.org/issue22005); even in Python >= 3.6 they require + passing a different encoding to pickle.loads, which may have undesirable + effects on other objects being unpickled. Work around this by instead + patching in a different encoding just for the argument to + datetime.datetime. + """ + + def find_class(self, module: str, name: str) -> Any: + if module == "datetime" and name == "datetime": + original_encoding = self.encoding # type: ignore[has-type] + self.encoding = "bytes" + + def datetime_factory(pickle_data): + self.encoding = original_encoding + return datetime(pickle_data) + + return datetime_factory + else: + return super().find_class(module, name) + + +class Python2FriendlyPickleVariable(PickleVariable): + def _loads(self, value: bytes) -> object: + return Python2FriendlyUnpickler(io.BytesIO(value)).load() + + +class Python2FriendlyPickle(SimpleProperty): + """Like `storm.properties.Pickle`, but also handles data from Python 2. + + We need this even though Launchpad no longer runs on Python 2, because + the session database may contain old data. + """ + + variable_class = Python2FriendlyPickleVariable + + @implementer(IUseSessionStore) @provider(IUseSessionStore) class SessionPkgData(StormBase): @@ -35,4 +87,24 @@ class SessionPkgData(StormBase): client_id = Unicode() product_id = Unicode() key = Unicode() - pickle = Pickle() + pickle = Python2FriendlyPickle() + + +class EnsureSessionClientID(NamedFunc): + __slots__ = () + name = "ensure_session_client_id" + + def __init__(self, hashed_client_id: str) -> None: + super().__init__(hashed_client_id) + + +class SetSessionPkgData(NamedFunc): + __slots__ = () + name = "set_session_pkg_data" + + def __init__( + self, hashed_client_id: str, product_id: str, key: str, value: object + ) -> None: + # Use protocol 2 for Python 2 compatibility. + pickled_value = pickle.dumps(value, protocol=2) + 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 @@ """PostgreSQL server side session storage for Zope3.""" import hashlib -import io -import pickle from collections.abc import MutableMapping -from datetime import datetime +from datetime import timedelta import six from lazr.restful.utils import get_current_browser_request +from storm.expr import Cast, Select from storm.zope.interfaces import IZStorm from zope.authentication.interfaces import IUnauthenticatedPrincipal from zope.component import getUtility from zope.interface import implementer +from lp.services.database.constants import UTC_NOW +from lp.services.session.model import ( + EnsureSessionClientID, + SessionData, + SessionPkgData, + SetSessionPkgData, +) from lp.services.webapp.interfaces import ( IClientIdManager, ISessionData, @@ -29,31 +35,6 @@ HOURS = 60 * MINUTES DAYS = 24 * HOURS -class Python2FriendlyUnpickler(pickle._Unpickler): - """An unpickler that handles Python 2 datetime objects. - - Python 3 versions before 3.6 fail to unpickle Python 2 datetime objects - (https://bugs.python.org/issue22005); even in Python >= 3.6 they require - passing a different encoding to pickle.loads, which may have undesirable - effects on other objects being unpickled. Work around this by instead - patching in a different encoding just for the argument to - datetime.datetime. - """ - - def find_class(self, module, name): - if module == "datetime" and name == "datetime": - original_encoding = self.encoding - self.encoding = "bytes" - - def datetime_factory(pickle_data): - self.encoding = original_encoding - return datetime(pickle_data) - - return datetime_factory - else: - return super().find_class(module, name) - - class PGSessionBase: store_name = "session" @@ -90,9 +71,6 @@ class PGSessionDataContainer(PGSessionBase): # using the session data. resolution = 9 * MINUTES - session_data_table_name = "SessionData" - session_pkg_data_table_name = "SessionPkgData" - def __getitem__(self, client_id): """See `ISessionDataContainer`.""" return PGSessionData(self, client_id) @@ -119,16 +97,16 @@ class PGSessionData(PGSessionBase): ).hexdigest() # Update the last access time in the db if it is out of date - table_name = session_data_container.session_data_table_name - query = """ - UPDATE %s SET last_accessed = CURRENT_TIMESTAMP - WHERE client_id = ? - AND last_accessed < CURRENT_TIMESTAMP - '%d seconds'::interval - """ % ( - table_name, - session_data_container.resolution, - ) - self.store.execute(query, (self.hashed_client_id,), noresult=True) + self.store.find( + SessionData, + SessionData.client_id == self.hashed_client_id, + SessionData.last_accessed + < UTC_NOW + - Cast( + timedelta(seconds=session_data_container.resolution), + "interval", + ), + ).set(last_accessed=UTC_NOW) def _ensureClientId(self): if self._have_ensured_client_id: @@ -137,8 +115,7 @@ class PGSessionData(PGSessionBase): # about our client id. We're doing it lazily to try and keep anonymous # users from having a session. self.store.execute( - "SELECT ensure_session_client_id(?)", - (self.hashed_client_id,), + Select(EnsureSessionClientID(self.hashed_client_id)), noresult=True, ) request = get_current_browser_request() @@ -198,29 +175,18 @@ class PGSessionPkgData(MutableMapping, PGSessionBase): def __init__(self, session_data, product_id): self.session_data = session_data self.product_id = six.ensure_text(product_id, "ascii") - self.table_name = ( - session_data.session_data_container.session_pkg_data_table_name - ) self._populate() _data_cache = None def _populate(self): self._data_cache = {} - query = ( - """ - SELECT key, pickle FROM %s WHERE client_id = ? - AND product_id = ? - """ - % self.table_name - ) - result = self.store.execute( - query, (self.session_data.hashed_client_id, self.product_id) + result = self.store.find( + (SessionPkgData.key, SessionPkgData.pickle), + SessionPkgData.client_id == self.session_data.hashed_client_id, + SessionPkgData.product_id == self.product_id, ) - for key, pickled_value in result: - value = Python2FriendlyUnpickler( - io.BytesIO(bytes(pickled_value)) - ).load() + for key, value in result: self._data_cache[key] = value def __getitem__(self, key): @@ -228,17 +194,16 @@ class PGSessionPkgData(MutableMapping, PGSessionBase): def __setitem__(self, key, value): key = six.ensure_text(key, "ascii") - # Use protocol 2 for Python 2 compatibility. - pickled_value = pickle.dumps(value, protocol=2) self.session_data._ensureClientId() self.store.execute( - "SELECT set_session_pkg_data(?, ?, ?, ?)", - ( - self.session_data.hashed_client_id, - self.product_id, - key, - pickled_value, + Select( + SetSessionPkgData( + self.session_data.hashed_client_id, + self.product_id, + key, + value, + ) ), noresult=True, ) @@ -259,22 +224,12 @@ class PGSessionPkgData(MutableMapping, PGSessionBase): # another process has inserted it and we should keep our grubby # fingers out of it. return - query = ( - """ - DELETE FROM %s - WHERE client_id = ? AND product_id = ? AND key = ? - """ - % self.table_name - ) - self.store.execute( - query, - ( - self.session_data.hashed_client_id, - self.product_id, - six.ensure_text(key, "ascii"), - ), - noresult=True, - ) + self.store.find( + SessionPkgData, + SessionPkgData.client_id == self.session_data.hashed_client_id, + SessionPkgData.product_id == self.product_id, + SessionPkgData.key == six.ensure_text(key, "ascii"), + ).remove() def __iter__(self): 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 = {toxinidir}/scripts/update-version-info.sh commands = mypy --follow-imports=silent \ - {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} + {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} [testenv:docs] basepython = python3
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp