Skip to content

Commit

Permalink
Improve resource caching (#973)
Browse files Browse the repository at this point in the history
  • Loading branch information
Marc-Andre-Rivet committed Oct 28, 2019
1 parent d13b87c commit 340d960
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 22 deletions.
2 changes: 1 addition & 1 deletion @plotly/webpack-dash-dynamic-import/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@plotly/webpack-dash-dynamic-import",
"version": "1.0.0",
"version": "1.1.0",
"description": "Webpack Plugin for Dynamic Import in Dash",
"repository": {
"type": "git",
Expand Down
63 changes: 53 additions & 10 deletions @plotly/webpack-dash-dynamic-import/src/index.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,72 @@
const resolveImportSource = `\
const fs = require('fs');

function getFingerprint() {
const package = fs.readFileSync('./package.json');
const packageJson = JSON.parse(package);

const timestamp = Math.round(Date.now() / 1000);
const version = packageJson.version.replace(/[.]/g, '_');

return `"v${version}m${timestamp}"`;
}

const resolveImportSource = () => `\
const getCurrentScript = function() {
let script = document.currentScript;
if (!script) {
/* Shim for IE11 and below */
/* Do not take into account async scripts and inline scripts */
const scripts = Array.from(document.getElementsByTagName('script')).filter(function(s) { return !s.async && !s.text && !s.textContent; });
script = scripts.slice(-1)[0];
}
return script;
};
const isLocalScript = function(script) {
return /\/_dash-components-suite\//.test(script.src);
};
Object.defineProperty(__webpack_require__, 'p', {
get: (function () {
let script = document.currentScript;
if (!script) {
/* Shim for IE11 and below */
/* Do not take into account async scripts and inline scripts */
const scripts = Array.from(document.getElementsByTagName('script')).filter(function(s) { return !s.async && !s.text && !s.textContent; });
script = scripts.slice(-1)[0];
}
let script = getCurrentScript();
var url = script.src.split('/').slice(0, -1).join('/') + '/';
return function() {
return url;
};
})()
});`
});
const __jsonpScriptSrc__ = jsonpScriptSrc;
jsonpScriptSrc = function(chunkId) {
let script = getCurrentScript();
let isLocal = isLocalScript(script);
let src = __jsonpScriptSrc__(chunkId);
if(!isLocal) {
return src;
}
const srcFragments = src.split('/');
const fileFragments = srcFragments.slice(-1)[0].split('.');
fileFragments.splice(1, 0, ${getFingerprint()});
srcFragments.splice(-1, 1, fileFragments.join('.'))
return srcFragments.join('/');
};
`

class WebpackDashDynamicImport {
apply(compiler) {
compiler.hooks.compilation.tap('WebpackDashDynamicImport', compilation => {
compilation.mainTemplate.hooks.requireExtensions.tap('WebpackDashDynamicImport > RequireExtensions', (source, chunk, hash) => {
return [
source,
resolveImportSource
resolveImportSource()
]
});
});
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### Added
- [#964](https://github.com/plotly/dash/pull/964) Adds support for preventing
updates in clientside functions.
updates in clientside functions.
- Reject all updates with `throw window.dash_clientside.PreventUpdate;`
- Reject a single output by returning `window.dash_clientside.no_update`
- [#899](https://github.com/plotly/dash/pull/899) Add support for async dependencies and components
- [#973](https://github.com/plotly/dash/pull/973) Adds support for resource caching and adds a fallback caching mechanism through etag

## [1.4.1] - 2019-10-17
### Fixed
Expand Down
34 changes: 29 additions & 5 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import dash_renderer

from .dependencies import Input, Output, State
from .fingerprint import build_fingerprint, check_fingerprint
from .resources import Scripts, Css
from .development.base_component import Component, ComponentRegistry
from . import exceptions
Expand Down Expand Up @@ -541,12 +542,14 @@ def _relative_url_path(relative_package_path="", namespace=""):

modified = int(os.stat(module_path).st_mtime)

return "{}_dash-component-suites/{}/{}?v={}&m={}".format(
return "{}_dash-component-suites/{}/{}".format(
self.config.requests_pathname_prefix,
namespace,
relative_package_path,
importlib.import_module(namespace).__version__,
modified,
build_fingerprint(
relative_package_path,
importlib.import_module(namespace).__version__,
modified,
),
)

srcs = []
Expand Down Expand Up @@ -676,6 +679,10 @@ def _generate_meta_html(self):

# Serve the JS bundles for each package
def serve_component_suites(self, package_name, path_in_package_dist):
path_in_package_dist, has_fingerprint = check_fingerprint(
path_in_package_dist
)

if package_name not in self.registered_paths:
raise exceptions.DependencyException(
"Error loading dependency.\n"
Expand Down Expand Up @@ -711,11 +718,28 @@ def serve_component_suites(self, package_name, path_in_package_dist):
package.__path__,
)

return flask.Response(
response = flask.Response(
pkgutil.get_data(package_name, path_in_package_dist),
mimetype=mimetype,
)

if has_fingerprint:
# Fingerprinted resources are good forever (1 year)
# No need for ETag as the fingerprint changes with each build
response.cache_control.max_age = 31536000 # 1 year
else:
# Non-fingerprinted resources are given an ETag that
# will be used / check on future requests
response.add_etag()
tag = response.get_etag()[0]

request_etag = flask.request.headers.get('If-None-Match')

if '"{}"'.format(tag) == request_etag:
response = flask.Response(None, status=304)

return response

def index(self, *args, **kwargs): # pylint: disable=unused-argument
scripts = self._generate_scripts_html()
css = self._generate_css_dist_html()
Expand Down
31 changes: 31 additions & 0 deletions dash/fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import re

build_regex = re.compile(r'^(?P<filename>[\w@-]+)(?P<extension>.*)$')

check_regex = re.compile(
r'^(?P<filename>.*)[.]v[\w-]+m[0-9a-fA-F]+(?P<extension>(?:(?:(?<![.])[.])?[\w])+)$'
)


def build_fingerprint(path, version, hash_value):
res = build_regex.match(path)

return '{}.v{}m{}{}'.format(
res.group('filename'),
str(version).replace('.', '_'),
hash_value,
res.group('extension'),
)


def check_fingerprint(path):
# Check if the resource has a fingerprint
res = check_regex.match(path)

# Resolve real resource name from fingerprinted resource path
return (
res.group('filename') + res.group('extension')
if res is not None
else path,
res is not None,
)
54 changes: 54 additions & 0 deletions tests/unit/test_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

from dash.fingerprint import build_fingerprint, check_fingerprint

version = 1
hash_value = 1

valid_resources = [
{'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1m1.8.6.min.js'},
{'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1_1_1m1234567890abcdef.8.6.min.js', 'version': '1.1.1', 'hash': '1234567890abcdef' },
{'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1_1_1-alpha_1m1234567890abcdef.8.6.min.js', 'version': '1.1.1-alpha.1', 'hash': '1234567890abcdef' },
{'path': 'dash.plotly.js', 'fingerprint': 'dash.v1m1.plotly.js'},
{'path': 'dash.plotly.j_s', 'fingerprint': 'dash.v1m1.plotly.j_s'},
{'path': 'dash.plotly.css', 'fingerprint': 'dash.v1m1.plotly.css'},
{'path': 'dash.plotly.xxx.yyy.zzz', 'fingerprint': 'dash.v1m1.plotly.xxx.yyy.zzz'}
]

valid_fingerprints = [
'react@16.v1_1_2m1571771240.8.6.min.js',
'dash.plotly.v1_1_1m1234567890.js',
'dash.plotly.v1_1_1m1234567890.j_s',
'dash.plotly.v1_1_1m1234567890.css',
'dash.plotly.v1_1_1m1234567890.xxx.yyy.zzz',
'dash.plotly.v1_1_1-alpha1m1234567890.js',
'dash.plotly.v1_1_1-alpha_3m1234567890.js',
'dash.plotly.v1_1_1m1234567890123.js',
'dash.plotly.v1_1_1m4bc3.js'
]

invalid_fingerprints = [
'dash.plotly.v1_1_1m1234567890..js',
'dash.plotly.v1_1_1m1234567890.',
'dash.plotly.v1_1_1m1234567890..',
'dash.plotly.v1_1_1m1234567890.js.',
'dash.plotly.v1_1_1m1234567890.j-s'
]

def test_fingerprint():
for resource in valid_resources:
# The fingerprint matches expectations
fingerprint = build_fingerprint(resource.get('path'), resource.get('version', version), resource.get('hash', hash_value))
assert fingerprint == resource.get('fingerprint')

(original_path, has_fingerprint) = check_fingerprint(fingerprint)
# The inverse operation returns that the fingerprint was valid and the original path
assert has_fingerprint
assert original_path == resource.get('path')

for resource in valid_fingerprints:
(_, has_fingerprint) = check_fingerprint(resource)
assert has_fingerprint

for resource in invalid_fingerprints:
(_, has_fingerprint) = check_fingerprint(resource)
assert not has_fingerprint
10 changes: 5 additions & 5 deletions tests/unit/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_external(mocker):
mocker.patch("dash_core_components._js_dist")
mocker.patch("dash_html_components._js_dist")
dcc._js_dist = _monkey_patched_js_dist # noqa: W0212
dcc.__version__ = 1
dcc.__version__ = "1.0.0"

app = dash.Dash(
__name__, assets_folder="tests/assets", assets_ignore="load_after.+.js"
Expand Down Expand Up @@ -66,7 +66,7 @@ def test_internal(mocker):
mocker.patch("dash_core_components._js_dist")
mocker.patch("dash_html_components._js_dist")
dcc._js_dist = _monkey_patched_js_dist # noqa: W0212,
dcc.__version__ = 1
dcc.__version__ = "1.0.0"

app = dash.Dash(
__name__, assets_folder="tests/assets", assets_ignore="load_after.+.js"
Expand All @@ -83,10 +83,10 @@ def test_internal(mocker):

assert resource == [
"/_dash-component-suites/"
"dash_core_components/external_javascript.js?v=1&m=1",
"dash_core_components/external_javascript.v1_0_0m1.js",
"/_dash-component-suites/"
"dash_core_components/external_css.css?v=1&m=1",
"/_dash-component-suites/" "dash_core_components/fake_dcc.js?v=1&m=1",
"dash_core_components/external_css.v1_0_0m1.css",
"/_dash-component-suites/" "dash_core_components/fake_dcc.v1_0_0m1.js",
]

assert (
Expand Down

6 comments on commit 340d960

@Aprilhuu
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this commit, my dash app always return the following error:

Exception on /_dash-component-suites/dash_bootstrap_components/_components.v0_7_2m1572382057/dash_bootstrap_components.min.js [GET]
Traceback (most recent call last):
File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 2446, in wsgi_app
response = self.full_dispatch_request()
File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1951, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1820, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1949, in full_dispatch_request
rv = self.dispatch_request()
File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1935, in dispatch_request
return self.view_functionsrule.endpoint
File "/usr/local/lib/python2.7/site-packages/dash/dash.py", line 700, in serve_component_suites
package_name, path_in_package_dist, self.registered_paths
DependencyException: "dash_bootstrap_components" is registered but the path requested is not valid.

The path requested: "_components.v0_7_2m1572382057/dash_bootstrap_components.min.js"

List of registered paths: defaultdict(<type 'set'>, {'dash_bootstrap_components': set(['_components/dash_bootstrap_components.min.js']), u'timelinevisualizer': set(['timelinevisualizer.min.js']), u'networkgraphvisualizer': set(['networkgraphvisualizer.min.js']), u'dash_table': set(['bundle.js', 'asynctable.js', 'asyncexport.js', 'asyncexport.js.map', 'asynctable.js.map', 'bundle.js.map']), 'dash_renderer': set(['dash_renderer.min.js.map', 'react@16.8.6.min.js', 'prop-types@15.7.2.min.js', 'react-dom@16.8.6.min.js', 'dash_renderer.min.js']), 'dash_core_components': set(['asyncgraph.js.map', 'asyncgraph.js', 'dash_core_components.min.js', 'dash_core_components.min.js.map', 'highlight.pack.js', 'asyncplotlyjs.js', 'asyncplotlyjs.js.map', 'plotly-1.50.1.min.js']), 'dash_html_components': set(['dash_html_components.min.js.map', 'dash_html_components.min.js']), u'sd_material_ui': set(['sd_material_ui.min.js']), u'datatable': set(['datatable.min.js'])})

172.17.0.1 - - [29/Oct/2019 20:58:31] "GET /_dash-component-suites/dash_bootstrap_components/_components.v0_7_2m1572382057/dash_bootstrap_components.min.js HTTP/1.1" 500 -

I know this has something to do with caching. Do you have any suggestions on how to fix this?

@alexcjohnson
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is your app structured such that the dash.Dash is instantiated before all the components are imported?
If so I think this will be covered by the fix we're working on right now #985 but you can fix it in the short term by explicitly importing all components right above where you instantiate the app.

@alexcjohnson
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh sorry... no this is a separate issue, seems like there's a problem with how we're adding the cache string now vs. the structure of dash_bootstrap_components. We're investigating!

@alexcjohnson
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by #987 - we'll release v1.5.1 shortly including that and one more fix.

@alexcjohnson
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/plotly/dash/releases/tag/v1.5.1 - @Aprilhuu can you confirm that this works now?

@Aprilhuu
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for your help! Everything works now.😊

Please sign in to comment.