diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c09f4c34..c557cee238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#2589](https://github.com/plotly/dash/pull/2589) CSS for input elements not scoped to Dash application +- [#2599](https://github.com/plotly/dash/pull/2599) Fix background callback cancel inputs used in multiple callbacks and mixed cancel inputs across pages. ## Changed diff --git a/dash/_callback.py b/dash/_callback.py index 82102aac95..836b2104d2 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -12,7 +12,6 @@ from .exceptions import ( PreventUpdate, WildcardInLongCallback, - DuplicateCallback, MissingLongCallbackManagerError, LongCallbackError, ) @@ -171,25 +170,8 @@ def callback( cancel_inputs = coerce_to_list(cancel) validate_long_inputs(cancel_inputs) - cancels_output = [Output(c.component_id, "id") for c in cancel_inputs] - - try: - - @callback(cancels_output, cancel_inputs, prevent_initial_call=True) - def cancel_call(*_): - job_ids = flask.request.args.getlist("cancelJob") - executor = ( - manager or context_value.get().background_callback_manager - ) - if job_ids: - for job_id in job_ids: - executor.terminate_job(job_id) - return NoUpdate() - - except DuplicateCallback: - pass # Already a callback to cancel, will get the proper jobs from the store. - long_spec["cancel"] = [c.to_dict() for c in cancel_inputs] + long_spec["cancel_inputs"] = cancel_inputs if cache_args_to_ignore: long_spec["cache_args_to_ignore"] = cache_args_to_ignore @@ -201,6 +183,7 @@ def cancel_call(*_): *_args, **_kwargs, long=long_spec, + manager=manager, ) @@ -238,6 +221,7 @@ def insert_callback( inputs_state_indices, prevent_initial_call, long=None, + manager=None, ): if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -269,6 +253,7 @@ def insert_callback( "long": long, "output": output, "raw_inputs": inputs, + "manager": manager, } callback_list.append(callback_spec) @@ -296,6 +281,7 @@ def register_callback( # pylint: disable=R0914 multi = True long = _kwargs.get("long") + manager = _kwargs.get("manager") output_indices = make_grouping_by_index(output, list(range(grouping_len(output)))) callback_id = insert_callback( @@ -309,6 +295,7 @@ def register_callback( # pylint: disable=R0914 inputs_state_indices, prevent_initial_call, long=long, + manager=manager, ) # pylint: disable=too-many-locals diff --git a/dash/dash.py b/dash/dash.py index 64b2a1ea02..6e7a1d2e5d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1194,9 +1194,6 @@ def dispatch(self): input_values ) = inputs_to_dict(inputs) g.state_values = inputs_to_dict(state) # pylint: disable=assigning-non-slot - g.background_callback_manager = ( - self._background_manager - ) # pylint: disable=E0237 changed_props = body.get("changedPropIds", []) g.triggered_inputs = [ # pylint: disable=assigning-non-slot {"prop_id": x, "value": input_values.get(x)} for x in changed_props @@ -1211,7 +1208,9 @@ def dispatch(self): try: cb = self.callback_map[output] func = cb["callback"] - + g.background_callback_manager = ( + cb.get("manager") or self._background_manager + ) g.ignore_register_page = cb.get("long", False) # Add args_grouping @@ -1316,6 +1315,35 @@ def _setup_server(self): _validate.validate_long_callbacks(self.callback_map) + cancels = {} + + for callback in self.callback_map.values(): + long = callback.get("long") + if not long: + continue + cancel = long.pop("cancel_inputs") + if cancel: + for c in cancel: + cancels[c] = long.get("manager") + + if cancels: + for cancel_input, manager in cancels.items(): + + # pylint: disable=cell-var-from-loop + @self.callback( + Output(cancel_input.component_id, "id"), + cancel_input, + prevent_initial_call=True, + manager=manager, + ) + def cancel_call(*_): + job_ids = flask.request.args.getlist("cancelJob") + executor = _callback.context_value.get().background_callback_manager + if job_ids: + for job_id in job_ids: + executor.terminate_job(job_id) + return no_update + def _add_assets_resource(self, url_path, file_path): res = {"asset_path": url_path, "filepath": file_path} if self.config.assets_external_path: diff --git a/tests/integration/long_callback/app_page_cancel.py b/tests/integration/long_callback/app_page_cancel.py new file mode 100644 index 0000000000..7ea1adebf8 --- /dev/null +++ b/tests/integration/long_callback/app_page_cancel.py @@ -0,0 +1,93 @@ +from dash import Dash, Input, Output, dcc, html, page_container, register_page + +import time + +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( + __name__, + use_pages=True, + pages_folder="", + long_callback_manager=long_callback_manager, +) + +app.layout = html.Div( + [ + dcc.Link("page1", "/"), + dcc.Link("page2", "/2"), + html.Button("Cancel", id="shared_cancel"), + page_container, + ] +) + + +register_page( + "one", + "/", + layout=html.Div( + [ + html.Button("start", id="start1"), + html.Button("cancel1", id="cancel1"), + html.Div("idle", id="progress1"), + html.Div("initial", id="output1"), + ] + ), +) +register_page( + "two", + "/2", + layout=html.Div( + [ + html.Button("start2", id="start2"), + html.Button("cancel2", id="cancel2"), + html.Div("idle", id="progress2"), + html.Div("initial", id="output2"), + ] + ), +) + + +@app.callback( + Output("output1", "children"), + Input("start1", "n_clicks"), + running=[ + (Output("progress1", "children"), "running", "idle"), + ], + cancel=[ + Input("cancel1", "n_clicks"), + Input("shared_cancel", "n_clicks"), + ], + background=True, + prevent_initial_call=True, + interval=300, +) +def on_click1(n_clicks): + time.sleep(2) + return f"Click {n_clicks}" + + +@app.callback( + Output("output2", "children"), + Input("start2", "n_clicks"), + running=[ + (Output("progress2", "children"), "running", "idle"), + ], + cancel=[ + Input("cancel2", "n_clicks"), + Input("shared_cancel", "n_clicks"), + ], + background=True, + prevent_initial_call=True, + interval=300, +) +def on_click1(n_clicks): + time.sleep(2) + return f"Click {n_clicks}" + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/tests/integration/long_callback/test_basic_long_callback.py b/tests/integration/long_callback/test_basic_long_callback.py index 93cc56c04d..fc19d3c0af 100644 --- a/tests/integration/long_callback/test_basic_long_callback.py +++ b/tests/integration/long_callback/test_basic_long_callback.py @@ -62,7 +62,14 @@ def setup_long_callback_app(manager_name, app_name): "--loglevel=info", ], preexec_fn=os.setpgrp, + stderr=subprocess.PIPE, ) + # Wait for the worker to be ready, if you cancel before it is ready, the job + # will still be queued. + for line in iter(worker.stderr.readline, ""): + if "ready" in line.decode(): + break + try: yield import_app(f"tests.integration.long_callback.{app_name}") finally: @@ -556,3 +563,37 @@ def test_lcbc015_diff_outputs_same_func(dash_duo, manager): for i in range(1, 3): dash_duo.find_element(f"#button-{i}").click() dash_duo.wait_for_text_to_equal(f"#output-{i}", f"Clicked on {i}") + + +def test_lcbc016_multi_page_cancel(dash_duo, manager): + with setup_long_callback_app(manager, "app_page_cancel") as app: + dash_duo.start_server(app) + dash_duo.find_element("#start1").click() + dash_duo.wait_for_text_to_equal("#progress1", "running") + dash_duo.find_element("#shared_cancel").click() + dash_duo.wait_for_text_to_equal("#progress1", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output1", "initial") + + dash_duo.find_element("#start1").click() + dash_duo.wait_for_text_to_equal("#progress1", "running") + dash_duo.find_element("#cancel1").click() + dash_duo.wait_for_text_to_equal("#progress1", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output1", "initial") + + dash_duo.server_url = dash_duo.server_url + "/2" + + dash_duo.find_element("#start2").click() + dash_duo.wait_for_text_to_equal("#progress2", "running") + dash_duo.find_element("#shared_cancel").click() + dash_duo.wait_for_text_to_equal("#progress2", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output2", "initial") + + dash_duo.find_element("#start2").click() + dash_duo.wait_for_text_to_equal("#progress2", "running") + dash_duo.find_element("#cancel2").click() + dash_duo.wait_for_text_to_equal("#progress2", "idle") + time.sleep(2.1) + dash_duo.wait_for_text_to_equal("#output2", "initial")