diff --git a/session_db/README.rst b/session_db/README.rst new file mode 100644 index 00000000000..abfc61c96a1 --- /dev/null +++ b/session_db/README.rst @@ -0,0 +1,93 @@ +==================== +Store sessions in DB +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0e92dd612d4d3860e95f5c06e6b9ea02c10fc402034c328effb4a9ac44b9cb73 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/17.0/session_db + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-17-0/server-tools-17-0-session_db + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Store sessions in a database instead of the filesystem. This simplifies +the configuration of horizontally scalable deployments, by avoiding the +need for a distributed filesystem to store the Odoo sessions. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Set this module in the server wide modules. + +Set a ``SESSION_DB_URI`` environment variable as a full postgresql +connection string, like ``postgres://user:passwd@server/db`` or ``db``. + +It is recommended to use a dedicated database for this module, and +possibly a dedicated postgres user for additional security. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Odoo SA +* ACSONE SA/NV + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-sbidoul| image:: https://github.com/sbidoul.png?size=40px + :target: https://github.com/sbidoul + :alt: sbidoul + +Current `maintainer `__: + +|maintainer-sbidoul| + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/session_db/__init__.py b/session_db/__init__.py new file mode 100644 index 00000000000..d051c561ca8 --- /dev/null +++ b/session_db/__init__.py @@ -0,0 +1 @@ +from . import pg_session_store diff --git a/session_db/__manifest__.py b/session_db/__manifest__.py new file mode 100644 index 00000000000..292ab2e741c --- /dev/null +++ b/session_db/__manifest__.py @@ -0,0 +1,8 @@ +{ + "name": "Store sessions in DB", + "version": "17.0.1.0.0", + "author": "Odoo SA,ACSONE SA/NV,Odoo Community Association (OCA)", + "license": "LGPL-3", + "website": "https://github.com/OCA/server-tools", + "maintainers": ["sbidoul"], +} diff --git a/session_db/i18n/session_db.pot b/session_db/i18n/session_db.pot new file mode 100644 index 00000000000..78d58d53fe0 --- /dev/null +++ b/session_db/i18n/session_db.pot @@ -0,0 +1,13 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py new file mode 100644 index 00000000000..ad47eb4fec0 --- /dev/null +++ b/session_db/pg_session_store.py @@ -0,0 +1,169 @@ +# Copyright (c) Odoo SA 2017 +# @author Nicolas Seinlet +# Copyright (c) ACSONE SA 2022 +# @author Stéphane Bidoul +import json +import logging +import os + +import psycopg2 + +import odoo +from odoo import http +from odoo.tools._vendor import sessions +from odoo.tools.func import lazy_property + +_logger = logging.getLogger(__name__) + +lock = None +if odoo.evented: + import gevent.lock + + lock = gevent.lock.RLock() +elif odoo.tools.config["workers"] == 0: + import threading + + lock = threading.RLock() + + +def with_lock(func): + def wrapper(*args, **kwargs): + try: + if lock is not None: + lock.acquire() + return func(*args, **kwargs) + finally: + if lock is not None: + lock.release() + + return wrapper + + +def with_cursor(func): + def wrapper(self, *args, **kwargs): + tries = 0 + while True: + tries += 1 + try: + self._ensure_connection() + return func(self, *args, **kwargs) + except (psycopg2.InterfaceError, psycopg2.OperationalError): + self._close_connection() + if tries > 4: + _logger.warning( + "session_db operation try %s/5 failed, aborting", tries + ) + raise + _logger.info("session_db operation try %s/5 failed, retrying", tries) + + return wrapper + + +class PGSessionStore(sessions.SessionStore): + def __init__(self, uri, session_class=None): + super().__init__(session_class) + self._uri = uri + self._cr = None + self._open_connection() + self._setup_db() + + def __del__(self): + self._close_connection() + + @with_lock + def _ensure_connection(self): + if self._cr is None: + self._open_connection() + + @with_lock + def _open_connection(self): + self._close_connection() + cnx = odoo.sql_db.db_connect(self._uri, allow_uri=True) + self._cr = cnx.cursor() + self._cr._cnx.autocommit = True + + @with_lock + def _close_connection(self): + """Return cursor to the pool.""" + if self._cr is not None: + try: + self._cr.close() + except Exception: # pylint: disable=except-pass + pass + self._cr = None + + @with_lock + @with_cursor + def _setup_db(self): + self._cr.execute( + """ + CREATE TABLE IF NOT EXISTS http_sessions ( + sid varchar PRIMARY KEY, + write_date timestamp without time zone NOT NULL, + payload text NOT NULL + ) + """ + ) + + @with_lock + @with_cursor + def save(self, session): + payload = json.dumps(dict(session)) + self._cr.execute( + """ + INSERT INTO http_sessions(sid, write_date, payload) + VALUES (%(sid)s, now() at time zone 'UTC', %(payload)s) + ON CONFLICT (sid) + DO UPDATE SET payload = %(payload)s, + write_date = now() at time zone 'UTC' + """, + dict(sid=session.sid, payload=payload), + ) + + @with_lock + @with_cursor + def delete(self, session): + self._cr.execute("DELETE FROM http_sessions WHERE sid=%s", (session.sid,)) + + @with_lock + @with_cursor + def get(self, sid): + self._cr.execute("SELECT payload FROM http_sessions WHERE sid=%s", (sid,)) + try: + data = json.loads(self._cr.fetchone()[0]) + except Exception: + return self.new() + + return self.session_class(data, sid, False) + + # This method is not part of the Session interface but is called nevertheless, + # so let's get it from FilesystemSessionStore. + rotate = http.FilesystemSessionStore.rotate + + @with_lock + @with_cursor + def vacuum(self, max_lifetime=http.SESSION_LIFETIME): + self._cr.execute( + "DELETE FROM http_sessions " + "WHERE now() at time zone 'UTC' - write_date > %s", + (f"{max_lifetime} seconds",), + ) + + +_original_session_store = http.root.__class__.session_store + + +@lazy_property +def session_store(self): + session_db_uri = os.environ.get("SESSION_DB_URI") + if session_db_uri: + _logger.debug("HTTP sessions stored in: db") + return PGSessionStore(session_db_uri, session_class=http.Session) + return _original_session_store.__get__(self, self.__class__) + + +# Monkey patch of standard methods +_logger.debug("Monkey patching session store") +http.root.__class__.session_store = session_store +# Reset the lazy property cache +vars(http.root).pop("session_store", None) diff --git a/session_db/pyproject.toml b/session_db/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/session_db/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/session_db/readme/DESCRIPTION.md b/session_db/readme/DESCRIPTION.md new file mode 100644 index 00000000000..079bebe862c --- /dev/null +++ b/session_db/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +Store sessions in a database instead of the filesystem. This simplifies +the configuration of horizontally scalable deployments, by avoiding the +need for a distributed filesystem to store the Odoo sessions. diff --git a/session_db/readme/USAGE.md b/session_db/readme/USAGE.md new file mode 100644 index 00000000000..8b1213ee3f9 --- /dev/null +++ b/session_db/readme/USAGE.md @@ -0,0 +1,7 @@ +Set this module in the server wide modules. + +Set a `SESSION_DB_URI` environment variable as a full postgresql +connection string, like `postgres://user:passwd@server/db` or `db`. + +It is recommended to use a dedicated database for this module, and +possibly a dedicated postgres user for additional security. diff --git a/session_db/static/description/icon.png b/session_db/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/session_db/static/description/icon.png differ diff --git a/session_db/static/description/index.html b/session_db/static/description/index.html new file mode 100644 index 00000000000..736129f27d1 --- /dev/null +++ b/session_db/static/description/index.html @@ -0,0 +1,428 @@ + + + + + + +Store sessions in DB + + + +
+

Store sessions in DB

+ + +

Beta License: LGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

Store sessions in a database instead of the filesystem. This simplifies +the configuration of horizontally scalable deployments, by avoiding the +need for a distributed filesystem to store the Odoo sessions.

+

Table of contents

+ +
+

Usage

+

Set this module in the server wide modules.

+

Set a SESSION_DB_URI environment variable as a full postgresql +connection string, like postgres://user:passwd@server/db or db.

+

It is recommended to use a dedicated database for this module, and +possibly a dedicated postgres user for additional security.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Odoo SA
  • +
  • ACSONE SA/NV
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

sbidoul

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/session_db/tests/__init__.py b/session_db/tests/__init__.py new file mode 100644 index 00000000000..22a56c88981 --- /dev/null +++ b/session_db/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pg_session_store diff --git a/session_db/tests/test_pg_session_store.py b/session_db/tests/test_pg_session_store.py new file mode 100644 index 00000000000..61e7500ec31 --- /dev/null +++ b/session_db/tests/test_pg_session_store.py @@ -0,0 +1,82 @@ +from unittest import mock + +import psycopg2 + +from odoo import http +from odoo.sql_db import connection_info_for +from odoo.tests.common import TransactionCase +from odoo.tools import config + +from odoo.addons.session_db.pg_session_store import PGSessionStore + + +def _make_postgres_uri( + login=None, password=None, host=None, port=None, database=None, **kwargs +): + uri = ["postgres://"] + if login: + uri.append(login) + if password: + uri.append(f":{password}") + uri.append("@") + if host: + uri.append(host) + if port: + uri.append(f":{port}") + uri.append("/") + if database: + uri.append(database) + return "".join(uri) + + +class TestPGSessionStore(TransactionCase): + def setUp(self): + super().setUp() + _, connection_info = connection_info_for(config["db_name"]) + self.session_store = PGSessionStore( + _make_postgres_uri(**connection_info), session_class=http.Session + ) + + def test_session_crud(self): + session = self.session_store.new() + session["test"] = "test" + self.session_store.save(session) + assert session.sid is not None + assert self.session_store.get(session.sid)["test"] == "test" + self.session_store.delete(session) + assert self.session_store.get(session.sid).get("test") is None + + def test_retry(self): + """Test that session operations are retried before failing""" + with mock.patch("odoo.sql_db.Cursor.execute") as mock_execute: + mock_execute.side_effect = psycopg2.OperationalError() + try: + self.session_store.get("abc") + except psycopg2.OperationalError: # pylint: disable=except-pass + pass + else: + # We don't use self.assertRaises because Odoo is overriding + # in a way that interferes with the Cursor.execute mock + raise AssertionError("expected psycopg2.OperationalError") + assert mock_execute.call_count == 5 + # when the error is resolved, it works again + self.session_store.get("abc") + + def test_retry_connect_fail(self): + with mock.patch("odoo.sql_db.Cursor.execute") as mock_execute, mock.patch( + "odoo.sql_db.db_connect" + ) as mock_db_connect: + mock_execute.side_effect = psycopg2.OperationalError() + mock_db_connect.side_effect = RuntimeError("connection failed") + # get fails, and a RuntimeError is raised when trying to reconnect + try: + self.session_store.get("abc") + except RuntimeError: # pylint: disable=except-pass + pass + else: + # We don't use self.assertRaises because Odoo is overriding + # in a way that interferes with the Cursor.execute mock + raise AssertionError("expected RuntimeError") + assert mock_execute.call_count == 1 + # when the error is resolved, it works again + self.session_store.get("abc")