Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable ETag header for dashboard GET requests #10963

Merged
merged 2 commits into from
Sep 29, 2020

Conversation

graceguo-supercat
Copy link

@graceguo-supercat graceguo-supercat commented Sep 18, 2020

SUMMARY

ETag header is widely used as efficient server-side caching mechanism. Superset had ETag support for explore_json, this PR is to expand the coverage to dashboard GET request.

When dashboard request come in, Superset need to gather all the datasource metadata and all the slices query parameters that are used for this dashboard, and return as a big blob of data. For a large dashboard in airbnb, we saw some dashboard can have 100+ datasources and 300+ slices. This server-side processing can take 4 seconds, and make the whole dashboard load became very slow.

eTag could be a good solution for large dashboards with less frequent changes:
782-imported-1443570282-782-imported-1443554751-54-original

TEST PLAN

  • Open a regular dashboard page, you can see it get 200 response from browser dev tool.
  • Reload the same dashboard from browser, you will see 304 response, and response time is really fast (~50 ms)
  • Try change dashboard layout, or modify one of the chart from another browser window.
  • Reload the same dashboard from browser, you will see 200 response again, since the cache is stale.

For example: regular GET request takes about 4 seconds. If enabled eTag header, 2nd request will get 304 and faster responded since there is no server-side processing:
Screen Shot 2020-09-25 at 10 55 56 AM

cc @betodealmeida @etr2460

@graceguo-supercat graceguo-supercat changed the title [WIP]feat: enable eTag header for dashboard [WIP]feat: enable eTag header for dashboard page load Sep 18, 2020
@graceguo-supercat graceguo-supercat changed the title [WIP]feat: enable eTag header for dashboard page load [WIP]feat: enable ETag header for dashboard page load Sep 19, 2020
@codecov-commenter
Copy link

codecov-commenter commented Sep 21, 2020

Codecov Report

Merging #10963 into master will decrease coverage by 0.70%.
The diff coverage is 71.42%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master   #10963      +/-   ##
==========================================
- Coverage   61.46%   60.76%   -0.71%     
==========================================
  Files         382      382              
  Lines       24139    24154      +15     
==========================================
- Hits        14836    14676     -160     
- Misses       9303     9478     +175     
Flag Coverage Δ
#python 60.76% <71.42%> (-0.71%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
superset/utils/decorators.py 53.52% <56.25%> (-1.48%) ⬇️
superset/views/utils.py 83.07% <76.47%> (-1.24%) ⬇️
superset/views/core.py 74.46% <83.33%> (-0.01%) ⬇️
superset/db_engines/hive.py 0.00% <0.00%> (-85.72%) ⬇️
superset/db_engine_specs/hive.py 53.90% <0.00%> (-30.08%) ⬇️
superset/db_engine_specs/presto.py 70.85% <0.00%> (-11.44%) ⬇️
superset/db_engine_specs/sqlite.py 65.62% <0.00%> (-9.38%) ⬇️
superset/utils/celery.py 82.14% <0.00%> (-3.58%) ⬇️
superset/examples/world_bank.py 97.10% <0.00%> (-2.90%) ⬇️
superset/examples/birth_names.py 97.36% <0.00%> (-2.64%) ⬇️
... and 9 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update dc893fe...bcf90b9. Read the comment docs.

@graceguo-supercat graceguo-supercat force-pushed the gg-DashboardETag branch 7 times, most recently from b879c96 to caf3745 Compare September 22, 2020 01:31
@graceguo-supercat graceguo-supercat changed the title [WIP]feat: enable ETag header for dashboard page load feat: enable ETag header for dashboard page load Sep 23, 2020
@graceguo-supercat graceguo-supercat changed the title feat: enable ETag header for dashboard page load feat: enable ETag header for dashboard GET requests Sep 23, 2020
@@ -72,6 +80,12 @@ def wrapper(*args: Any, **kwargs: Any) -> ETagResponseMixin:
if request.method == "POST":
return f(*args, **kwargs)

# if it is dashboard request but feature is not eabled,
# do not use cache
is_dashboard = is_dashboard_request(kwargs)
Copy link
Member

Choose a reason for hiding this comment

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

Instead of adding a helper function, maybe we can just expand dashboard_id_or_slug in wrapper and make this code more transparent?

def wrapper(*args: Any, dashboard_id_or_slug: str=None, **kwargs: Any) -> ETagResponseMixin:
  # ...
  is_dashboard = dashboard_id_or_slug is not None

Better yet, we should probably aim for keeping all dashboard specific logics out of etag_cache so it stays generic. Maybe add a skip= parameter that runs the feature flag check.

@etag_cache(skip=lambda: is_feature_enabled("ENABLE_DASHBOARD_ETAG_HEADER"))
def etag_cache(
    max_age: int,
    check_perms: Callable[..., Any],
    skip: Optional[Callable[..., Any]] = None,
) -> Callable[..., Any]:

  def decorator(f: Callable[..., Any]) -> Callable[..., Any]:

    def wrapper(*args: Any, **kwargs: Any) -> ETagResponseMixin:

      check_perms(*args, **kwargs)

      if request.method == "POST" or (skip and skip(*args, **kwargs)):
        return f(*args, **kwargs)

Copy link
Author

@graceguo-supercat graceguo-supercat Sep 25, 2020

Choose a reason for hiding this comment

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

thanks! after introduce this skip, dashboard_id is not needed in decorator function any more.

tzinfo=timezone.utc
).astimezone(tz=None)
if latest_changed_on.timestamp() > latest_record.timestamp():
response = None
Copy link
Member

Choose a reason for hiding this comment

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

Can probably rename check_latest_changed_on to get_latest_changed_on and do

if get_latest_changed_on:
  latest_changed_on = get_latest_changed_on(*args, **kwargs)
  if response and response.last_modified and response.last_modified < latest_changed_on:
    response = None
else:
  latest_changed_on = datetime.utcnow()

Copy link
Author

Choose a reason for hiding this comment

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

fixed.

@bkyryliuk
Copy link
Member

@graceguo-supercat can you please describe what effect this change will have on

  1. permission checks, e.g, if user gained access, what they still see permission denied if that is cached
  2. how it will interact with chart cache
  3. how it will interact with datasource / dashboard changes

@graceguo-supercat
Copy link
Author

graceguo-supercat commented Sep 24, 2020

etag_cache decorator works this way:

  • explore_json flow:
  1. http request come in

  2. check_slice_perms. No matter has cached response, if no permission, response with error.

  3. if method is POST, run query (not use cache). Otherwise check if this request has cache.

  4. if no cached response, run query and create a cache key for the response

  5. send response to client-side, with eTag header, last_modified header and expiration time header. last_modified time is now (response time).

  • dashboard flow:
  1. http request come in

  2. check_dashboard_perms. No matter has cached response, if no permission, response with error.

  3. if method is POST, or feature is not enabled, run dashboard function to build response (not use cache). Otherwise check if this request has cache.

  4. if no cached response, run dashboard function.

  5. if has cache, compare cached time with dashboard last modified time: it could be dashboard's metadata was changed (dashboard's changed_on), or any of its slices was changed (slice's changed_on). If cache is stale, run dashboard function.

  6. send response to client-side, with eTag header, last_modified header and expiration time header. last_modified time is max of (dashboard' changed_on and its slices changed_on)

@bkyryliuk
Copy link
Member

response to client-side, with eTag header, last_modified header and expiration time header. last_modified time is max of (dashboard' changed_on and its slices change

great thanks, it looks like it addresses question #1, what about 2 and 3?

e.g. for #2

  • if chart was cached for ~10 hours and etag cached the dashboard, does it mean that chart on this dashboard will be cached for 10 hours + etag expiration time ?
    for Implementing my own highcharts wrapper #3
  • how it will affect dashboard / datasource changes like annotations, changed to the default filters, css rules, etc would those changes be cached for the user as well?

Sorry if those questions do not make sense or are obvious. I am just trying to understand the flow here

@graceguo-supercat
Copy link
Author

graceguo-supercat commented Sep 24, 2020

Note:
For explore_json requests, we want to cache query results.

For dashboard requests, we want to cache dashboard bootstrap data, which includes datasource metadata, slices parameters, etc. Dashboard front-end js use datasource metadata, slice parameters, and dashboard filters to build query and fetch query results, the results itself are not in the dashboard bootstrap data.

This PR will only focus on dashboard's cache stale logic. i do not want to change current slice cache behavior.

#2
if chart was not modified for ~10 hours, I assume this means slice entity are not modified, like query parameters, datasource used, it doesn't mean the query results is not modified. Since query results is not part of dashboard bootstrap data, it's not included in the dashboard cache either.

#3:
annotations: dashboard has no annotation. If slice's annotation was changed, slice entity's params and changed_on attribute will be changed.
changed to the default filters, css rules, etc: these change will be stored in dashboard metadata, and will change dashboard changed_on attribute.


def get_dashboard_latest_changed_on(_self: Any, dashboard_id_or_slug: str) -> datetime:
"""
Get latest changed datetime for a dashboard. The change could be dashboard
Copy link
Member

Choose a reason for hiding this comment

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

s/Get latest changed datetime for a dashboard.
/Get latest changed datetime for a dashboard and it's charts

Copy link
Author

Choose a reason for hiding this comment

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

yes. I rename it to get_dashboard_latest_changedon_dt.

Copy link
Member

Choose a reason for hiding this comment

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

can we get more specific with _self type ?

Copy link
Author

Choose a reason for hiding this comment

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

I don't know what type to use here. do you have suggestion? (Sorry i am not an expert in Python)

@@ -34,6 +34,10 @@
logger = logging.getLogger(__name__)


def is_dashboard_request(kwargs: Any) -> bool:
return kwargs.get("dashboard_id_or_slug") is not None
Copy link
Member

Choose a reason for hiding this comment

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

doesn't seem robust, it it possible to validate via uri path or just pass a param ?

Copy link
Author

Choose a reason for hiding this comment

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

after introduce this skip, dashboard_id_or_slug is not needed in decorator function any more. this check is removed.

latest_changed_on = check_latest_changed_on(*args, **kwargs)
if response and response.last_modified:
latest_record = response.last_modified.replace(
tzinfo=timezone.utc
Copy link
Member

Choose a reason for hiding this comment

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

this assumes that superset server runs in utc zone, it may be safer to make it as a superset config variable

Copy link
Author

@graceguo-supercat graceguo-supercat Sep 25, 2020

Choose a reason for hiding this comment

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

this convert, .replace(tzinfo) is not necessary, removed.

return dashboard


def get_datasources_from_dashboard(
Copy link
Member

Choose a reason for hiding this comment

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

looks like a good candidate for the Dashboard class method

Copy link
Author

@graceguo-supercat graceguo-supercat Sep 25, 2020

Choose a reason for hiding this comment

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

this is a little confusing: Dashboard class has a get datasources function. So i use it in check_dashboard_perms function. But this function is to group slices by datasources, and the result will be used by another feature:
https://github.com/apache/incubator-superset/blob/ba009b7c09d49f2932fd10269882c901bc020c1d/superset/views/core.py#L1626
Instead of datasource.data, datasource.data_for_slices(slices) can reduce the initial dashboard data load size.

So right now i removed this helper function from utils, and build dict in the dashboard function. But i rename datasource to slices_by_datasources for clarification.

return datasources


def get_dashboard_latest_changed_on(_self: Any, dashboard_id_or_slug: str) -> datetime:
Copy link
Member

Choose a reason for hiding this comment

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

what is _self here? ideally we should avoid Any types

Copy link
Author

Choose a reason for hiding this comment

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

Please see other functions that used by decorator:
This function takes `self` since it must have the same signature as the the decorated method.

@@ -490,6 +538,26 @@ def check_slice_perms(_self: Any, slice_id: int) -> None:
viz_obj.raise_for_access()


def check_dashboard_perms(_self: Any, dashboard_id_or_slug: str) -> None:
Copy link
Member

Choose a reason for hiding this comment

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

best practice is to have a unit test for every function, it would be great if you could add some

Copy link
Author

Choose a reason for hiding this comment

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

Yes, i agree. but this function is refactored out from dashboard function. it is tested in
https://github.com/apache/incubator-superset/blob/448a41a4e7563cafadea1e03feb5980151e8b56d/tests/security_tests.py#L665
I assume the old unit tests didn't break will be good enough.

Copy link
Member

Choose a reason for hiding this comment

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

a good practice is to incrementally improve the state of the code, however it will be your call here

@bkyryliuk
Copy link
Member

Note:
For explore_json requests, we want to cache query results.

For dashboard requests, we want to cache dashboard bootstrap data, which includes datasource metadata, slices parameters, etc. Dashboard front-end js use datasource metadata, slice parameters, and dashboard filters to build query and fetch query results, the results itself are not in the dashboard bootstrap data.

This PR will only focus on dashboard's cache stale logic. i do not want to change current slice cache behavior.

#2
if chart was not modified for ~10 hours, I assume this means slice entity are not modified, like query parameters, datasource used, it doesn't mean the query results is not modified. Since query results is not part of dashboard bootstrap data, it's not included in the dashboard cache either.

#3:
annotations: dashboard has no annotation. If slice's annotation was changed, slice entity's params and changed_on attribute will be changed.
changed to the default filters, css rules, etc: these change will be stored in dashboard metadata, and will change dashboard changed_on attribute.

Big thanks for the explanation!

@graceguo-supercat graceguo-supercat force-pushed the gg-DashboardETag branch 3 times, most recently from eb3956a to 1c39347 Compare September 25, 2020 22:15
@graceguo-supercat graceguo-supercat force-pushed the gg-DashboardETag branch 2 times, most recently from fda20c7 to c69afd0 Compare September 28, 2020 08:25
Copy link
Member

@bkyryliuk bkyryliuk left a comment

Choose a reason for hiding this comment

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

LG%nits

):
response = None
else:
content_changed_time = datetime.utcnow()
Copy link
Member

Choose a reason for hiding this comment

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

add a comment here why content_changed_time is set to now()

Copy link
Author

Choose a reason for hiding this comment

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

We use this content_changed_time as cache's last_modified time.
for dashboard content_changed_time is dashboard entity's latest updated time (like metadata, chart metadata changed time etc). this data is from a callback function.
for explore_json, the cache is query results and there is no entity's latest modified time to use. so we use request time (now) as cache's last_modified time.

Copy link
Member

Choose a reason for hiding this comment

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

I know generalizing too soon is not a good practice, but I wonder if we should pass a callable called is_stale here. It would simplify the decorator logic, and since it would be defined closer to the dashboard it might simplify the logic there as well.

Copy link
Author

Choose a reason for hiding this comment

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

at first i thought is_stale is a good idea. but when start refactor it, i found last_modified time is needed by decorator function(to set header). So is_stale (only boolean value) is not enough.
So instead of using is_stale return true or false, I prefer to keep get_last_modified, and use last_modified time to invalid cache.


def get_dashboard_latest_changed_on(_self: Any, dashboard_id_or_slug: str) -> datetime:
"""
Get latest changed datetime for a dashboard. The change could be dashboard
Copy link
Member

Choose a reason for hiding this comment

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

can we get more specific with _self type ?

Copy link
Member

@betodealmeida betodealmeida left a comment

Choose a reason for hiding this comment

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

This looks great, @graceguo-supercat! I left a small comment on how to possible simplify the code a little b it, but I'm not 100% sure it would help.

):
response = None
else:
content_changed_time = datetime.utcnow()
Copy link
Member

Choose a reason for hiding this comment

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

I know generalizing too soon is not a good practice, but I wonder if we should pass a callable called is_stale here. It would simplify the decorator logic, and since it would be defined closer to the dashboard it might simplify the logic there as well.

Copy link
Member

@ktmud ktmud left a comment

Choose a reason for hiding this comment

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

Sorry, found a couple of more nits, both are optional

def etag_cache(
max_age: int,
check_perms: Callable[..., Any],
get_latest_changed_on: Optional[Callable[..., Any]] = None,
Copy link
Member

Choose a reason for hiding this comment

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

Can we rename get_latest_changed_on to get_last_modified just to be more consistent with the response attribute? Imagine in future refactor response.last_modified is renamed to something else, you would know this function is definitely related by searching for last_modified.

Copy link
Author

Choose a reason for hiding this comment

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

agree. rename it to get_last_modified.

@etag_cache(
0,
check_perms=check_dashboard_perms,
get_latest_changed_on=get_dashboard_changedon_dt,
Copy link
Member

@ktmud ktmud Sep 29, 2020

Choose a reason for hiding this comment

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

Nit: it's a little weird to have both changed_on and changedon, but up to your.

@graceguo-supercat graceguo-supercat merged commit 6633409 into apache:master Sep 29, 2020
graceguo-supercat pushed a commit to graceguo-supercat/superset that referenced this pull request Oct 8, 2020
graceguo-supercat pushed a commit that referenced this pull request Oct 8, 2020
@ktmud ktmud mentioned this pull request Oct 12, 2020
6 tasks
auxten pushed a commit to auxten/incubator-superset that referenced this pull request Nov 20, 2020
* feat: add etag for dashboard load requests

* fix review comments
auxten pushed a commit to auxten/incubator-superset that referenced this pull request Nov 20, 2020
@mistercrunch mistercrunch added 🏷️ bot A label used by `supersetbot` to keep track of which PR where auto-tagged with release labels 🚢 0.38.0 labels Mar 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🏷️ bot A label used by `supersetbot` to keep track of which PR where auto-tagged with release labels size/L 🚢 0.38.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants