Mercurial > wikked
changeset 322:566d229d1b30
SQL cache refactor to try and do things correctly with SQLAlchemy.
When running in a Flask application, try and create the SQL engine only once and
re-use it between requests. Also create the session factory only on init.
author | Ludovic Chabant <ludovic@chabant.com> |
---|---|
date | Sat, 11 Oct 2014 22:05:48 -0700 |
parents | 19003fc8cbdd |
children | 8ea76932ea3a |
files | wikked/commands/web.py wikked/db/base.py wikked/db/sql.py wikked/web.py |
diffstat | 4 files changed, 128 insertions(+), 47 deletions(-) [+] |
line wrap: on
line diff
--- a/wikked/commands/web.py Thu Oct 09 13:17:21 2014 -0700 +++ b/wikked/commands/web.py Sat Oct 11 22:05:48 2014 -0700 @@ -81,8 +81,10 @@ # Create/import the app. from wikked.web import app + app.wiki_params = ctx.params + ctx.wiki.db.hookupWebApp(app) - app.wiki_params = ctx.params + # Update if needed. if bool(app.config.get('WIKI_UPDATE_ON_START')): ctx.wiki.updateAll()
--- a/wikked/db/base.py Thu Oct 09 13:17:21 2014 -0700 +++ b/wikked/db/base.py Sat Oct 11 22:05:48 2014 -0700 @@ -21,7 +21,7 @@ """ Called after a new wiki has been created. """ pass - def close(self, commit, exception): + def close(self, exception): """ Called when the wiki is disposed of. """ pass
--- a/wikked/db/sql.py Thu Oct 09 13:17:21 2014 -0700 +++ b/wikked/db/sql.py Sat Oct 11 22:05:48 2014 -0700 @@ -4,6 +4,7 @@ import string import logging import datetime +import threading from sqlalchemy import ( create_engine, and_, @@ -15,6 +16,7 @@ relationship, backref, load_only, subqueryload, joinedload, Load) from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.orm.session import Session from wikked.db.base import Database, PageListNotFound from wikked.page import Page, PageData, FileSystemPage from wikked.utils import split_page_url @@ -150,6 +152,79 @@ 'SQLPage') +class _WikkedSQLSession(Session): + """ A session that can get its engine to bind to from a state + object. This effectively makes it possible to setup a session + factory before we have an engine. + """ + def __init__(self, state, autocommit=False, autoflush=True): + self.state = state + super(_WikkedSQLSession, self).__init__( + autocommit=autocommit, + autoflush=autoflush, + bind=state.engine) + + +class _SQLStateBase(object): + """ Base class for the 2 different state holder objects used by + the `SQLDatabase` cache. One is the "default" one, which is used + by command line wikis. The other is used when running the Flask + application, and stays active for as long as the application is + running. This makes it possible to reuse the same engine and + session factory. + """ + def __init__(self, engine_url, scopefunc=None): + logger.debug("Creating SQL state.") + self.engine_url = engine_url + self._engine = None + self._engine_lock = threading.Lock() + self.session = scoped_session( + self._createScopedSession, + scopefunc=scopefunc) + + @property + def engine(self): + """ Returns the SQL engine. An engine will be created if there + wasn't one already. """ + if self._engine is None: + with self._engine_lock: + if self._engine is None: + logger.debug("Creating SQL engine with URL: %s" % + self.engine_url) + self._engine = create_engine(self.engine_url, + convert_unicode=True) + return self._engine + + def close(self, exception=None): + logger.debug("Closing SQL session.") + self.session.remove() + + def _createScopedSession(self, **kwargs): + """ The factory for SQL sessions. When a session is created, + it will pull the engine in, which will lazily create the + engine. """ + logger.debug("Creating SQL session.") + return _WikkedSQLSession(self, **kwargs) + + +class _SharedSQLState(_SQLStateBase): + """ The shared state, used when running the Flask application. + """ + def __init__(self, app, engine_url, scopefunc): + super(_SharedSQLState, self).__init__(engine_url, scopefunc) + self.app = app + + def postInitHook(self, wiki): + wiki.db._state = self + + +class _EmbeddedSQLState(_SQLStateBase): + """ The embedded state, used by default in command line wikis. + """ + def __init__(self, engine_url): + super(_EmbeddedSQLState, self).__init__(engine_url) + + class SQLDatabase(Database): """ A database cache based on SQL. """ @@ -159,25 +234,48 @@ Database.__init__(self) self.engine_url = config.get('wiki', 'database_url') self.auto_update = config.getboolean('wiki', 'auto_update') - self._engine = None - self._session = None + self._state = None + self._state_lock = threading.Lock() + + def hookupWebApp(self, app): + """ Hook up a Flask application with all the stuff we need. + This includes patching every wiki created during request + handling to use our `_SharedSQLState` object, and removing + any active sessions after the request is done. """ + from flask import g, _app_ctx_stack + + logger.debug("Hooking up Flask app for SQL database.") + state = _SharedSQLState(app, self.engine_url, + _app_ctx_stack.__ident_func__) + app.wikked_post_init.append(state.postInitHook) + + @app.teardown_appcontext + def shutdown_session(exception=None): + # See if the wiki, and its DB, were used... + wiki = getattr(g, 'wiki', None) + if wiki and wiki.db._state: + wiki.db._state.close( + exception=exception) + return exception @property def engine(self): - if self._engine is None: - logger.debug("Creating SQL engine from URL: %s" % self.engine_url) - self._engine = create_engine(self.engine_url, convert_unicode=True) - return self._engine + return self._getState().engine @property def session(self): - if self._session is None: - logger.debug("Opening database from URL: %s" % self.engine_url) - self._session = scoped_session(sessionmaker( - autocommit=False, - autoflush=False, - bind=self.engine)) - return self._session + return self._getState().session + + def _getState(self): + """ If no state has been specified yet, use the default + embedded one (which means no sharing of engines or session + factories with any other wikis. """ + if self._state is not None: + return self._state + with self._state_lock: + if self._state is None: + self._state = _EmbeddedSQLState(self.engine_url) + return self._state def _needsSchemaUpdate(self): if (self.engine_url == 'sqlite://' or @@ -230,11 +328,9 @@ def start(self, wiki): self.wiki = wiki - def close(self, commit, exception): - if self._session is not None: - if commit and exception is None: - self._session.commit() - self._session.remove() + def close(self, exception): + if self._state is not None: + self._state.close(exception) def reset(self, page_infos): logger.debug("Re-creating SQL database.")
--- a/wikked/web.py Thu Oct 09 13:17:21 2014 -0700 +++ b/wikked/web.py Sat Oct 11 22:05:48 2014 -0700 @@ -75,14 +75,24 @@ app.logger.debug("Creating Flask application...") +# This lets components further modify the wiki that's created for +# each request. +app.wikked_post_init = [] + + +# We'll hook this up to the post-page-update event, where we want to +# clear all cached page lists. def remove_page_lists(wiki, url): wiki.db.removeAllPageLists() +# When requested, set the wiki as a request global. def get_wiki(): wiki = getattr(g, '_wiki', None) if wiki is None: wiki = Wiki(app.wiki_params) + for i in app.wikked_post_init: + i(wiki) wiki.post_update_hooks.append(remove_page_lists) wiki.start() g.wiki = wiki @@ -93,33 +103,6 @@ app.wiki_params = WikiParameters(wiki_root) -# Set the wiki as a request global, and open/close the database. -# NOTE: this must happen before the login extension is registered -# because it will also add a `before_request` callback, and -# that will call our authentication handler that needs -# access to the context instance for the wiki. -@app.before_request -def before_request(): - pass - - -@app.teardown_request -def teardown_request(exception): - return exception - - -# SQLAlchemy. -# TODO: this totally assumes things about the wiki's DB API. -@app.teardown_appcontext -def shutdown_session(exception=None): - wiki = getattr(g, 'wiki', None) - if wiki: - wiki.db.close( - commit=app.config['SQL_COMMIT_ON_TEARDOWN'], - exception=exception) - return exception - - # Login extension. def user_loader(username): wiki = get_wiki()