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

Long callback refactor #2039

Merged
merged 41 commits into from
Jun 30, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
bfbd59e
Handle long callback errors.
T4rk1n Apr 26, 2022
dbacfcc
:hankey: Add test long callback error.
T4rk1n Apr 26, 2022
ac59e59
Add lock to diskcache
T4rk1n May 6, 2022
e2d3d5e
Fix test long callback error.
T4rk1n May 6, 2022
c3855ef
Merge branch 'dev' into long-callback-errors
T4rk1n May 6, 2022
fb4cef9
Handle no update in celery long callbacks.
T4rk1n May 6, 2022
fa94a40
Use diskcache lock
T4rk1n May 9, 2022
7688306
Stricter no_update check.
T4rk1n May 9, 2022
20c72b9
Handle no update in multi output.
T4rk1n May 9, 2022
2f9fe06
Add long callback test lock.
T4rk1n May 9, 2022
9ed5a3f
Merge branch 'dev' into long-callback-errors
T4rk1n May 17, 2022
9cab5b4
Clean up no_update.
T4rk1n May 17, 2022
9509213
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 10, 2022
3a207ce
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 13, 2022
2b22ff3
Replace long callback interval with request polling handled in renderer.
T4rk1n Jun 13, 2022
c5f6fff
Fix test_grouped_callbacks
T4rk1n Jun 13, 2022
fc9a77b
Fix cbva002
T4rk1n Jun 14, 2022
40bd173
Fix celery cancel.
T4rk1n Jun 14, 2022
28a6d77
Fix callback_map
T4rk1n Jun 14, 2022
1c1c7a2
Update callback docstrings.
T4rk1n Jun 15, 2022
55455f9
Add test short interval.
T4rk1n Jun 15, 2022
cc9b09b
Add error if no manager.
T4rk1n Jun 15, 2022
0501fd9
Fix error message assert
T4rk1n Jun 15, 2022
43e42ea
Remove leftover _NoUpdate.
T4rk1n Jun 20, 2022
de5ee34
Update dash/dash.py
T4rk1n Jun 20, 2022
08356f0
Hide arguments.
T4rk1n Jun 20, 2022
edd7fd6
Add test side update.
T4rk1n Jun 21, 2022
2fd56e9
Redux devtools ignore reloadRequest actions.
T4rk1n Jun 21, 2022
da2b01f
Long callbacks side update to trigger other callbacks.
T4rk1n Jun 21, 2022
bfc3c8f
Add test long callback pattern matching.
T4rk1n Jun 21, 2022
077733c
Add circular check for long callbacks side outputs.
T4rk1n Jun 21, 2022
638dacf
Add test long callback context.
T4rk1n Jun 21, 2022
9df3082
Support callback context in long callbacks.
T4rk1n Jun 21, 2022
ccb53b9
Proper callback_context, replace flask.g context with contextvars.
T4rk1n Jun 22, 2022
8525c73
Fix cancel.
T4rk1n Jun 23, 2022
34bd81d
Back to flask.g for timing_information.
T4rk1n Jun 23, 2022
b2ac6ce
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 23, 2022
02c15dc
Manage previous outdated running jobs.
T4rk1n Jun 28, 2022
a003f5a
Merge branch 'dev' into long-callback-errors
T4rk1n Jun 29, 2022
529ec8e
Lock selenium <=4.2.0
T4rk1n Jun 30, 2022
2fb1cfa
Update changelog.
T4rk1n Jun 30, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@

class NoUpdate:
# pylint: disable=too-few-public-methods
pass

def to_plotly_json(self): # pylint: disable=no-self-use
return {"no_update": "no_update"}


GLOBAL_CALLBACK_LIST = []
Expand Down
47 changes: 46 additions & 1 deletion dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from dash import dcc
from dash import html
from dash import dash_table
from ._callback import NoUpdate

from .fingerprint import build_fingerprint, check_fingerprint
from .resources import Scripts, Css
Expand Down Expand Up @@ -1169,7 +1170,17 @@ def long_callback(self, *_args, **_kwargs): # pylint: disable=too-many-statemen
)
store_id = f"_long_callback_store_{long_callback_id}"
store_component = dcc.Store(id=store_id, data={})
self._extra_components.extend([interval_component, store_component])
error_id = f"_long_callback_error_{long_callback_id}"
error_store_component = dcc.Store(id=error_id)
error_dummy = f"_long_callback_error_dummy_{long_callback_id}"
self._extra_components.extend(
[
interval_component,
store_component,
error_store_component,
dcc.Store(id=error_dummy),
]
)

# Compute full component plus property name for the cancel dependencies
cancel_prop_ids = tuple(
Expand Down Expand Up @@ -1214,6 +1225,7 @@ def callback(_triggers, user_store_data, user_callback_args):
in_progress=[val for (_, _, val) in running],
progress=clear_progress,
user_store_data=user_store_data,
error=NoUpdate(),
)

# Look up progress value if a job is in progress
Expand All @@ -1228,6 +1240,28 @@ def callback(_triggers, user_store_data, user_callback_args):
# to pending key (hash of data requested by client)
user_store_data["current_key"] = pending_key

if isinstance(result, dict) and result.get("long_callback_error"):
error = result.get("long_callback_error")
print(
result["long_callback_error"]["tb"],
file=sys.stderr,
)
return dict(
error=f"An error occurred inside a long callback: {error['msg']}\n"
+ error["tb"],
user_callback_output=NoUpdate(),
in_progress=[val for (_, _, val) in running],
interval_disabled=pending_job is None,
progress=clear_progress,
user_store_data=user_store_data,
)

if (
isinstance(result, dict)
and result.get("no_update") == "no_update"
):
result = NoUpdate()

# Disable interval if this value was pulled from cache.
# If this value was the result of a background calculation, don't
# disable yet. If no other calculations are in progress,
Expand All @@ -1240,6 +1274,7 @@ def callback(_triggers, user_store_data, user_callback_args):
in_progress=[val for (_, _, val) in running],
progress=clear_progress,
user_store_data=user_store_data,
error=NoUpdate(),
)
if progress_value:
return dict(
Expand All @@ -1248,6 +1283,7 @@ def callback(_triggers, user_store_data, user_callback_args):
in_progress=[val for (_, val, _) in running],
progress=progress_value or {},
user_store_data=user_store_data,
error=NoUpdate(),
)

# Check if there is a running calculation that can now
Expand All @@ -1273,8 +1309,16 @@ def callback(_triggers, user_store_data, user_callback_args):
in_progress=[val for (_, val, _) in running],
progress=clear_progress,
user_store_data=user_store_data,
error=NoUpdate(),
)

self.clientside_callback(
"function (error) {throw new Error(error)}",
Output(error_dummy, "data"),
[Input(error_id, "data")],
prevent_initial_call=True,
)

return self.callback(
inputs=dict(
_triggers=dict(
Expand All @@ -1290,6 +1334,7 @@ def callback(_triggers, user_store_data, user_callback_args):
in_progress=[dep for (dep, _, _) in running],
progress=progress,
user_store_data=Output(store_id, "data"),
error=Output(error_id, "data"),
),
prevent_initial_call=prevent_initial_call,
)(callback)
Expand Down
36 changes: 29 additions & 7 deletions dash/long_callback/managers/celery_manager.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import json
import inspect
import hashlib
import traceback

from _plotly_utils.utils import PlotlyJSONEncoder

from dash._callback import NoUpdate
from dash.exceptions import PreventUpdate
from dash.long_callback.managers import BaseLongCallbackManager


Expand Down Expand Up @@ -132,13 +136,31 @@ def _set_progress(progress_value):
cache.set(progress_key, json.dumps(progress_value, cls=PlotlyJSONEncoder))

maybe_progress = [_set_progress] if progress else []
if isinstance(args_deps, dict):
user_callback_output = fn(*maybe_progress, **user_callback_args)
elif isinstance(args_deps, (list, tuple)):
user_callback_output = fn(*maybe_progress, *user_callback_args)
else:
user_callback_output = fn(*maybe_progress, user_callback_args)

cache.set(result_key, json.dumps(user_callback_output, cls=PlotlyJSONEncoder))
try:
if isinstance(args_deps, dict):
user_callback_output = fn(*maybe_progress, **user_callback_args)
elif isinstance(args_deps, (list, tuple)):
user_callback_output = fn(*maybe_progress, *user_callback_args)
else:
user_callback_output = fn(*maybe_progress, user_callback_args)
except PreventUpdate:
cache.set(result_key, json.dumps(NoUpdate(), cls=PlotlyJSONEncoder))
except Exception as err: # pylint: disable=broad-except
cache.set(
result_key,
json.dumps(
{
"long_callback_error": {
"msg": str(err),
"tb": traceback.format_exc(),
}
},
),
)
else:
cache.set(
result_key, json.dumps(user_callback_output, cls=PlotlyJSONEncoder)
)

return job_fn
44 changes: 37 additions & 7 deletions dash/long_callback/managers/diskcache_manager.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import traceback
from multiprocessing import Lock

from . import BaseLongCallbackManager
from ..._callback import NoUpdate
from ...exceptions import PreventUpdate

_pending_value = "__$pending__"

_lock = Lock()


class OriginalException:
pass


class DiskcacheLongCallbackManager(BaseLongCallbackManager):
def __init__(self, cache=None, cache_by=None, expire=None):
Expand Down Expand Up @@ -137,15 +148,34 @@ def get_result(self, key, job):
def _make_job_fn(fn, cache, progress, args_deps):
def job_fn(result_key, progress_key, user_callback_args):
def _set_progress(progress_value):
cache.set(progress_key, progress_value)
with _lock:
cache.set(progress_key, progress_value)

maybe_progress = [_set_progress] if progress else []
if isinstance(args_deps, dict):
user_callback_output = fn(*maybe_progress, **user_callback_args)
elif isinstance(args_deps, (list, tuple)):
user_callback_output = fn(*maybe_progress, *user_callback_args)

try:
if isinstance(args_deps, dict):
user_callback_output = fn(*maybe_progress, **user_callback_args)
elif isinstance(args_deps, (list, tuple)):
user_callback_output = fn(*maybe_progress, *user_callback_args)
else:
user_callback_output = fn(*maybe_progress, user_callback_args)
except PreventUpdate:
with _lock:
cache.set(result_key, NoUpdate())
except Exception as err: # pylint: disable=broad-except
with _lock:
cache.set(
result_key,
{
"long_callback_error": {
"msg": str(err),
"tb": traceback.format_exc(),
}
},
)
else:
user_callback_output = fn(*maybe_progress, user_callback_args)
cache.set(result_key, user_callback_output)
with _lock:
cache.set(result_key, user_callback_output)

return job_fn
45 changes: 45 additions & 0 deletions tests/integration/long_callback/app_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import os
import time

import dash
from dash import html
from dash.dependencies import Input, Output
from dash.exceptions import PreventUpdate

from tests.integration.long_callback.utils import get_long_callback_manager

long_callback_manager = get_long_callback_manager()
handle = long_callback_manager.handle

app = dash.Dash(__name__, long_callback_manager=long_callback_manager)
app.enable_dev_tools(debug=True, dev_tools_ui=True)
app.layout = html.Div(
[
html.Div([html.P(id="output", children=["Button not clicked"])]),
html.Button(id="button", children="Run Job!"),
]
)


@app.long_callback(
output=Output("output", "children"),
inputs=Input("button", "n_clicks"),
running=[
(Output("button", "disabled"), True, False),
],
prevent_initial_call=True,
)
def callback(n_clicks):
if os.getenv("LONG_CALLBACK_MANAGER") != "celery":
# Diskmanager needs some time, celery takes too long.
time.sleep(1)
if n_clicks == 2:
raise Exception("bad error")

if n_clicks == 4:
raise PreventUpdate
return f"Clicked {n_clicks} times"


if __name__ == "__main__":
app.run_server(debug=True)
37 changes: 36 additions & 1 deletion tests/integration/long_callback/test_basic_long_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def kill(proc_pid):


if "REDIS_URL" in os.environ:
managers = ["diskcache", "celery"]
managers = ["celery", "diskcache"]
else:
print("Skipping celery tests because REDIS_URL is not defined")
managers = ["diskcache"]
Expand Down Expand Up @@ -426,3 +426,38 @@ def test_lcbc007_validation_layout(dash_duo, manager):

assert not dash_duo.redux_state_is_loading
assert dash_duo.get_logs() == []


def test_lcbc008_long_callbacks_error(dash_duo, manager):
with setup_long_callback_app(manager, "app_error") as app:
dash_duo.start_server(
app,
debug=True,
use_reloader=False,
use_debugger=True,
dev_tools_hot_reload=False,
dev_tools_ui=True,
)

clicker = dash_duo.find_element("#button")

def click_n_wait():
clicker.click()
dash_duo.wait_for_element("#button:disabled")
dash_duo.wait_for_element("#button:not([disabled])")

clicker.click()
dash_duo.wait_for_text_to_equal("#output", "Clicked 1 times")

click_n_wait()
dash_duo.wait_for_contains_text(
".dash-fe-error__title", "An error occurred inside a long callback:"
)

click_n_wait()
dash_duo.wait_for_text_to_equal("#output", "Clicked 3 times")

click_n_wait()
dash_duo.wait_for_text_to_equal("#output", "Clicked 3 times")
click_n_wait()
dash_duo.wait_for_text_to_equal("#output", "Clicked 5 times")