Added preemptive caching of / and /i18n/<locale>/messages.js

Introduced a @preemptively_cached decorator that for decorated views
persists the provided data in ~/.octoprint/data/preemptive_flask_cache.yaml
in a list indexed by the view's path if the data is not yet part of the list.

During initialization the server will iterate over the persisted paths and data
and for each persisted path and entry in the list initialize a temporary WSGI
environment based on the data (which is interpretated as keyword arguments
to werkzeug's EnvironmentBuilder) which will then be used to call the view
function in the correct context.

The current implementation for / and /i18n/<locale>/messages.js utilizes
that decorator to allow preemptive caching of those views (/ being probably
the most expensive one in the whole core application) utilizing request base URLs
(internal access, external access, reverse proxy with prefix url etc) that had been
encountered in the past.

Through the new config setting server.preemptiveCaching.exceptions it is
possible to define a set of base URLs to never cache. Preemptive caching can
be globally disabled by setting devel.cache.preemptive to false.
This commit is contained in:
Gina Häußge 2015-11-23 17:35:25 +01:00
parent d0eca800fb
commit fab5fc4899
4 changed files with 103 additions and 5 deletions

View file

@ -467,6 +467,10 @@ class Server():
implementation.on_after_startup()
pluginLifecycleManager.add_callback("enabled", call_on_after_startup)
# when we are through with that we also run our preemptive cache
if settings().getBoolean(["devel", "cache", "preemptive"]):
self._execute_preemptive_flask_caching()
import threading
threading.Thread(target=work).start()
ioloop.add_callback(on_after_startup)
@ -526,7 +530,7 @@ class Server():
if default_language is not None and not default_language == "_default" and default_language in LANGUAGES:
return Locale.negotiate([default_language], LANGUAGES)
return request.accept_languages.best_match(LANGUAGES)
return Locale.parse(request.accept_languages.best_match(LANGUAGES))
def _setup_logging(self, debug, logConf=None):
defaultConfig = {
@ -663,6 +667,30 @@ class Server():
self._register_template_plugins()
def _execute_preemptive_flask_caching(self):
from octoprint.server.util.flask import get_preemptive_cache_data
from werkzeug.test import EnvironBuilder
cache_data = get_preemptive_cache_data()
if not cache_data:
return
def execute_caching():
for route in sorted(cache_data.keys(), key=lambda x: (x.count("/"), x)):
entries = cache_data[route]
for kwargs in entries:
try:
self._logger.info("Preemptively caching {} for {!r}".format(route, kwargs))
builder = EnvironBuilder(**kwargs)
app(builder.get_environ(), lambda *a, **kw: None)
except:
self._logger.exception("Error while trying to preemptively cache {} for {!r}".format(route, kwargs))
import threading
cache_thread = threading.Thread(target=execute_caching, name="Preemptive Cache Worker")
cache_thread.daemon = True
cache_thread.start()
def _register_template_plugins(self):
template_plugins = pluginManager.get_implementations(octoprint.plugin.TemplatePlugin)
for plugin in template_plugins:

View file

@ -376,6 +376,69 @@ def cache_check_response_headers(response):
return False
_preemptive_flask_cache = "preemptive_flask_cache.yaml"
def preemptively_cached(data, unless=None):
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
if not (callable(unless) and unless()):
entry_data = data
if callable(entry_data):
entry_data = entry_data()
if entry_data is not None:
from flask import request
from octoprint.util import atomic_write
import yaml
data_folder = settings().getBaseFolder("data")
cache_data_file = os.path.join(data_folder, _preemptive_flask_cache)
cache_data = get_preemptive_cache_data()
if not request.path in cache_data:
cache_data[request.path] = []
cache_data_for_path = cache_data.get(request.path, [])
if all(map(lambda entry: entry_data != entry, cache_data_for_path)):
logging.getLogger(__name__).info("Adding {} for {!r} to views to preemptively cache".format(request.path, entry_data))
cache_data[request.path] = cache_data_for_path + [entry_data]
try:
with atomic_write(cache_data_file, "wb", prefix="octoprint-{}-".format(_preemptive_flask_cache[:-len(".yaml")]), suffix=".yaml") as handle:
yaml.safe_dump(cache_data, handle,default_flow_style=False, indent=" ", allow_unicode=True)
except:
logging.getLogger(__name__).exception("Error while writing {}".format(_preemptive_flask_cache))
return f(*args, **kwargs)
return decorated_function
return decorator
def get_preemptive_cache_data(root=None):
import yaml
data_folder = settings().getBaseFolder("data")
cache_data_file = os.path.join(data_folder, _preemptive_flask_cache)
if not os.path.isfile(cache_data_file):
return dict()
cache_data = None
try:
with open(cache_data_file, "r") as f:
cache_data = yaml.safe_load(f)
except:
logging.getLogger(__name__).exception("Error while reading {}".format(_preemptive_flask_cache))
if cache_data is None:
cache_data = dict()
if root:
return cache_data.get(root, dict())
else:
return cache_data
def add_non_caching_response_headers(response):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0"

View file

@ -29,10 +29,11 @@ _valid_div_re = re.compile("[a-zA-Z_-]+")
@app.route("/")
@util.flask.cached(timeout=-1,
refreshif=lambda: util.flask.cache_check_headers() or "_refresh" in request.values,
key=lambda: "view:{}:{}".format(request.base_url, g.locale),
key=lambda: "view:{}:{}".format(request.base_url, g.locale.language if g.locale else "default"),
unless_response=util.flask.cache_check_response_headers)
@util.flask.preemptively_cached(data=lambda: dict(path=request.path, base_url=request.url_root, query_string="l10n={}".format(g.locale.language)) if g.locale else None,
unless=lambda: request.url_root in settings().get(["server", "preemptiveCache", "exceptions"]))
def index():
#~~ a bunch of settings
enable_gcodeviewer = settings().getBoolean(["gcodeViewer", "enabled"])
@ -404,7 +405,9 @@ def robotsTxt():
@app.route("/i18n/<string:locale>/<string:domain>.js")
@util.flask.cached(timeout=-1,
refreshif=lambda: util.flask.cache_check_headers() or "_refresh" in request.values,
key=lambda: "view:{}:{}".format(request.base_url, g.locale))
key=lambda: "view:{}".format(request.base_url))
@util.flask.preemptively_cached(data=lambda: dict(path=request.path, base_url=request.url_root) if g.locale else None,
unless=lambda: request.url_root in settings().get(["server", "preemptiveCache", "exceptions"]))
def localeJs(locale, domain):
messages = dict()
plural_expr = None

View file

@ -114,6 +114,9 @@ default_settings = {
"diskspace": {
"warning": 500 * 1024 * 1024, # 500 MB
"critical": 200 * 1024 * 1024, # 200 MB
},
"preemptiveCache": {
"exceptions": []
}
},
"webcam": {
@ -262,7 +265,8 @@ default_settings = {
"devel": {
"stylesheet": "css",
"cache": {
"enabled": True
"enabled": True,
"preemptive": True
},
"webassets": {
"minify": False,