fix: revert eTag cache feature for dashboard (#11203)

* revert #11137

* revert #10963
This commit is contained in:
Grace Guo 2020-10-08 12:15:08 -07:00 committed by GitHub
parent b6728d87a0
commit a10e86ab31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 50 additions and 154 deletions

View File

@ -297,7 +297,6 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
# Experimental feature introducing a client (browser) cache # Experimental feature introducing a client (browser) cache
"CLIENT_CACHE": False, "CLIENT_CACHE": False,
"ENABLE_EXPLORE_JSON_CSRF_PROTECTION": False, "ENABLE_EXPLORE_JSON_CSRF_PROTECTION": False,
"ENABLE_DASHBOARD_ETAG_HEADER": False,
"ENABLE_TEMPLATE_PROCESSING": False, "ENABLE_TEMPLATE_PROCESSING": False,
"KV_STORE": False, "KV_STORE": False,
"PRESTO_EXPAND_DATA": False, "PRESTO_EXPAND_DATA": False,

View File

@ -17,7 +17,7 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps from functools import wraps
from typing import Any, Callable, Iterator, Optional from typing import Any, Callable, Iterator
from contextlib2 import contextmanager from contextlib2 import contextmanager
from flask import request from flask import request
@ -46,13 +46,7 @@ def stats_timing(stats_key: str, stats_logger: BaseStatsLogger) -> Iterator[floa
stats_logger.timing(stats_key, now_as_float() - start_ts) stats_logger.timing(stats_key, now_as_float() - start_ts)
def etag_cache( def etag_cache(max_age: int, check_perms: Callable[..., Any]) -> Callable[..., Any]:
max_age: int,
check_perms: Callable[..., Any],
get_last_modified: Optional[Callable[..., Any]] = None,
skip: Optional[Callable[..., Any]] = None,
must_revalidate: Optional[bool] = False,
) -> Callable[..., Any]:
""" """
A decorator for caching views and handling etag conditional requests. A decorator for caching views and handling etag conditional requests.
@ -75,12 +69,10 @@ def etag_cache(
# for POST requests we can't set cache headers, use the response # for POST requests we can't set cache headers, use the response
# cache nor use conditional requests; this will still use the # cache nor use conditional requests; this will still use the
# dataframe cache in `superset/viz.py`, though. # dataframe cache in `superset/viz.py`, though.
if request.method == "POST" or (skip and skip(*args, **kwargs)): if request.method == "POST":
return f(*args, **kwargs) return f(*args, **kwargs)
response = None response = None
last_modified = get_last_modified and get_last_modified(*args, **kwargs)
if cache: if cache:
try: try:
# build the cache key from the function arguments and any # build the cache key from the function arguments and any
@ -97,37 +89,17 @@ def etag_cache(
raise raise
logger.exception("Exception possibly due to cache backend.") logger.exception("Exception possibly due to cache backend.")
# if cache is stale? # if no response was cached, compute it using the wrapped function
if (
response
and last_modified
and response.last_modified
and response.last_modified < last_modified
):
response = None
if response is None: if response is None:
# if no response was cached, compute it using the wrapped function
response = f(*args, **kwargs) response = f(*args, **kwargs)
# set expiration headers: # add headers for caching: Last Modified, Expires and ETag
# Last-Modified, Expires, Cache-Control, ETag response.cache_control.public = True
response.last_modified = last_modified or datetime.utcnow() response.last_modified = datetime.utcnow()
expiration = max_age if max_age != 0 else FAR_FUTURE expiration = max_age if max_age != 0 else FAR_FUTURE
response.expires = response.last_modified + timedelta( response.expires = response.last_modified + timedelta(
seconds=expiration seconds=expiration
) )
# when needed, instruct the browser to always revalidate cache
if must_revalidate:
# `Cache-Control: no-cache` asks the browser to always store
# the cache, but also must validate it with the server.
response.cache_control.no_cache = True
else:
# `Cache-Control: Public` asks the browser to always store
# the cache.
response.cache_control.public = True
response.add_etag() response.add_etag()
# if we have a cache, store the response from the request # if we have a cache, store the response from the request

View File

@ -26,17 +26,7 @@ from urllib import parse
import backoff import backoff
import pandas as pd import pandas as pd
import simplejson as json import simplejson as json
from flask import ( from flask import abort, flash, g, Markup, redirect, render_template, request, Response
abort,
flash,
g,
make_response,
Markup,
redirect,
render_template,
request,
Response,
)
from flask_appbuilder import expose from flask_appbuilder import expose
from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.decorators import has_access, has_access_api from flask_appbuilder.security.decorators import has_access, has_access_api
@ -124,12 +114,9 @@ from superset.views.utils import (
_deserialize_results_payload, _deserialize_results_payload,
apply_display_max_row_limit, apply_display_max_row_limit,
bootstrap_user_data, bootstrap_user_data,
check_dashboard_perms,
check_datasource_perms, check_datasource_perms,
check_slice_perms, check_slice_perms,
get_cta_schema_name, get_cta_schema_name,
get_dashboard,
get_dashboard_changedon_dt,
get_dashboard_extra_filters, get_dashboard_extra_filters,
get_datasource_info, get_datasource_info,
get_form_data, get_form_data,
@ -171,13 +158,6 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def __repr__(self) -> str:
"""Determinate string representation of the view instance for etag_cache."""
return "Superset.views.core.Superset@v{}{}".format(
self.appbuilder.app.config["VERSION_STRING"],
self.appbuilder.app.config["VERSION_SHA"],
)
@has_access_api @has_access_api
@expose("/datasources/") @expose("/datasources/")
def datasources(self) -> FlaskResponse: def datasources(self) -> FlaskResponse:
@ -1605,33 +1585,49 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
return json_success(json.dumps({"published": dash.published})) return json_success(json.dumps({"published": dash.published}))
@has_access @has_access
@etag_cache(
0,
check_perms=check_dashboard_perms,
get_last_modified=get_dashboard_changedon_dt,
skip=lambda _self, dashboard_id_or_slug: not is_feature_enabled(
"ENABLE_DASHBOARD_ETAG_HEADER"
),
must_revalidate=True,
)
@expose("/dashboard/<dashboard_id_or_slug>/") @expose("/dashboard/<dashboard_id_or_slug>/")
def dashboard( # pylint: disable=too-many-locals def dashboard( # pylint: disable=too-many-locals
self, dashboard_id_or_slug: str self, dashboard_id_or_slug: str
) -> FlaskResponse: ) -> FlaskResponse:
"""Server side rendering for a dashboard""" """Server side rendering for a dashboard"""
dash = get_dashboard(dashboard_id_or_slug) session = db.session()
qry = session.query(Dashboard)
if dashboard_id_or_slug.isdigit():
qry = qry.filter_by(id=int(dashboard_id_or_slug))
else:
qry = qry.filter_by(slug=dashboard_id_or_slug)
slices_by_datasources = defaultdict(list) dash = qry.one_or_none()
if not dash:
abort(404)
datasources = defaultdict(list)
for slc in dash.slices: for slc in dash.slices:
datasource = slc.datasource datasource = slc.datasource
if datasource: if datasource:
slices_by_datasources[datasource].append(slc) datasources[datasource].append(slc)
if config["ENABLE_ACCESS_REQUEST"]:
for datasource in datasources:
if datasource and not security_manager.can_access_datasource(
datasource
):
flash(
__(
security_manager.get_datasource_access_error_msg(datasource)
),
"danger",
)
return redirect(
"superset/request_access/?" f"dashboard_id={dash.id}&"
)
# Filter out unneeded fields from the datasource payload # Filter out unneeded fields from the datasource payload
datasources_payload = { datasources_payload = {
datasource.uid: datasource.data_for_slices(slices) datasource.uid: datasource.data_for_slices(slices)
if is_feature_enabled("REDUCE_DASHBOARD_BOOTSTRAP_PAYLOAD") if is_feature_enabled("REDUCE_DASHBOARD_BOOTSTRAP_PAYLOAD")
else datasource.data else datasource.data
for datasource, slices in slices_by_datasources.items() for datasource, slices in datasources.items()
} }
dash_edit_perm = check_ownership( dash_edit_perm = check_ownership(
@ -1665,7 +1661,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
if is_feature_enabled("REMOVE_SLICE_LEVEL_LABEL_COLORS"): if is_feature_enabled("REMOVE_SLICE_LEVEL_LABEL_COLORS"):
# dashboard metadata has dashboard-level label_colors, # dashboard metadata has dashboard-level label_colors,
# so remove slice-level label_colors from its form_data # so remove slice-level label_colors from its form_data
for slc in dashboard_data.get("slices") or []: for slc in dashboard_data.get("slices"):
form_data = slc.get("form_data") form_data = slc.get("form_data")
form_data.pop("label_colors", None) form_data.pop("label_colors", None)
@ -1699,17 +1695,15 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
json.dumps(bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser) json.dumps(bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser)
) )
return make_response( return self.render_template(
self.render_template( "superset/dashboard.html",
"superset/dashboard.html", entry="dashboard",
entry="dashboard", standalone_mode=standalone_mode,
standalone_mode=standalone_mode, title=dash.dashboard_title,
title=dash.dashboard_title, custom_css=dashboard_data.get("css"),
custom_css=dashboard_data.get("css"), bootstrap_data=json.dumps(
bootstrap_data=json.dumps( bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser
bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser ),
),
)
) )
@api @api

View File

@ -16,28 +16,21 @@
# under the License. # under the License.
import logging import logging
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime from datetime import date
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Union from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Union
from urllib import parse from urllib import parse
import msgpack import msgpack
import pyarrow as pa import pyarrow as pa
import simplejson as json import simplejson as json
from flask import abort, flash, g, redirect, request from flask import g, request
from flask_appbuilder.security.sqla import models as ab_models from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User from flask_appbuilder.security.sqla.models import User
from flask_babel import gettext as __ from flask_babel import gettext as __
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
import superset.models.core as models import superset.models.core as models
from superset import ( from superset import app, dataframe, db, is_feature_enabled, result_set
app,
dataframe,
db,
is_feature_enabled,
result_set,
security_manager,
)
from superset.connectors.connector_registry import ConnectorRegistry from superset.connectors.connector_registry import ConnectorRegistry
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetException, SupersetSecurityException from superset.exceptions import SupersetException, SupersetSecurityException
@ -307,36 +300,6 @@ def get_time_range_endpoints(
CONTAINER_TYPES = ["COLUMN", "GRID", "TABS", "TAB", "ROW"] CONTAINER_TYPES = ["COLUMN", "GRID", "TABS", "TAB", "ROW"]
def get_dashboard(dashboard_id_or_slug: str,) -> Dashboard:
session = db.session()
qry = session.query(Dashboard)
if dashboard_id_or_slug.isdigit():
qry = qry.filter_by(id=int(dashboard_id_or_slug))
else:
qry = qry.filter_by(slug=dashboard_id_or_slug)
dashboard = qry.one_or_none()
if not dashboard:
abort(404)
return dashboard
def get_dashboard_changedon_dt(_self: Any, dashboard_id_or_slug: str) -> datetime:
"""
Get latest changed datetime for a dashboard. The change could be dashboard
metadata change, or any of its slice data change.
This function takes `self` since it must have the same signature as the
the decorated method.
"""
dash = get_dashboard(dashboard_id_or_slug)
dash_changed_on = dash.changed_on
slices_changed_on = max([s.changed_on for s in dash.slices])
# drop microseconds in datetime to match with last_modified header
return max(dash_changed_on, slices_changed_on).replace(microsecond=0)
def get_dashboard_extra_filters( def get_dashboard_extra_filters(
slice_id: int, dashboard_id: int slice_id: int, dashboard_id: int
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
@ -547,26 +510,6 @@ def check_slice_perms(_self: Any, slice_id: int) -> None:
viz_obj.raise_for_access() viz_obj.raise_for_access()
def check_dashboard_perms(_self: Any, dashboard_id_or_slug: str) -> None:
"""
Check if user can access a cached response from explore_json.
This function takes `self` since it must have the same signature as the
the decorated method.
"""
dash = get_dashboard(dashboard_id_or_slug)
datasources = list(dash.datasources)
if app.config["ENABLE_ACCESS_REQUEST"]:
for datasource in datasources:
if datasource and not security_manager.can_access_datasource(datasource):
flash(
__(security_manager.get_datasource_access_error_msg(datasource)),
"danger",
)
redirect("superset/request_access/?" f"dashboard_id={dash.id}&")
def _deserialize_results_payload( def _deserialize_results_payload(
payload: Union[bytes, str], query: Query, use_msgpack: Optional[bool] = False payload: Union[bytes, str], query: Query, use_msgpack: Optional[bool] = False
) -> Dict[str, Any]: ) -> Dict[str, Any]:

View File

@ -67,7 +67,6 @@ from superset.utils.core import (
from superset.utils import schema from superset.utils import schema
from superset.views.utils import ( from superset.views.utils import (
build_extra_filters, build_extra_filters,
get_dashboard_changedon_dt,
get_form_data, get_form_data,
get_time_range_endpoints, get_time_range_endpoints,
) )
@ -1135,14 +1134,3 @@ class TestUtils(SupersetTestCase):
assert get_form_data_token({"token": "token_abcdefg1"}) == "token_abcdefg1" assert get_form_data_token({"token": "token_abcdefg1"}) == "token_abcdefg1"
generated_token = get_form_data_token({}) generated_token = get_form_data_token({})
assert re.match(r"^token_[a-z0-9]{8}$", generated_token) is not None assert re.match(r"^token_[a-z0-9]{8}$", generated_token) is not None
def test_get_dashboard_changedon_dt(self) -> None:
slug = "world_health"
dashboard = db.session.query(Dashboard).filter_by(slug=slug).one()
dashboard_last_changedon = dashboard.changed_on
slices = dashboard.slices
slices_last_changedon = max([slc.changed_on for slc in slices])
# drop microsecond in datetime
assert get_dashboard_changedon_dt(self, slug) == max(
dashboard_last_changedon, slices_last_changedon
).replace(microsecond=0)