From d13a9179ae196578fd644e09cb3127f59f55078c Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 13 Jul 2023 09:58:44 -0400 Subject: [PATCH 1/9] Create a single cancel callback for each unique input. --- dash/_callback.py | 20 +------------------- dash/dash.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 82102aac95..ed2db1a210 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 diff --git a/dash/dash.py b/dash/dash.py index 64b2a1ea02..f6e46ac8f5 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1316,6 +1316,30 @@ def _setup_server(self): _validate.validate_long_callbacks(self.callback_map) + cancels = set() + + for callback in self.callback_map.values(): + cancel = callback.get("long", {}).pop("cancel_inputs") + if cancel: + cancels.update(cancel) + + if cancels: + for cancel_input in cancels: + + # pylint: disable=cell-var-from-loop + @self.callback( + Output(cancel_input.component_id, "id"), + cancel_input, + prevent_initial_call=True, + ) + 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: From b5c12871a2c706c3507e2350606d9473091f84d9 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 13 Jul 2023 12:19:59 -0400 Subject: [PATCH 2/9] Fix cancel with manager from parameter. --- dash/_callback.py | 5 +++++ dash/dash.py | 20 ++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index ed2db1a210..836b2104d2 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -183,6 +183,7 @@ def callback( *_args, **_kwargs, long=long_spec, + manager=manager, ) @@ -220,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 @@ -251,6 +253,7 @@ def insert_callback( "long": long, "output": output, "raw_inputs": inputs, + "manager": manager, } callback_list.append(callback_spec) @@ -278,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( @@ -291,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 f6e46ac8f5..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,21 +1315,26 @@ def _setup_server(self): _validate.validate_long_callbacks(self.callback_map) - cancels = set() + cancels = {} for callback in self.callback_map.values(): - cancel = callback.get("long", {}).pop("cancel_inputs") + long = callback.get("long") + if not long: + continue + cancel = long.pop("cancel_inputs") if cancel: - cancels.update(cancel) + for c in cancel: + cancels[c] = long.get("manager") if cancels: - for cancel_input in 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") From b9a07a02079bd03a99bb983f3830aadfc24f2346 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 13 Jul 2023 12:34:32 -0400 Subject: [PATCH 3/9] Add test page cancel. --- .../long_callback/app_page_cancel.py | 93 +++++++++++++++++++ .../long_callback/test_basic_long_callback.py | 34 +++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/integration/long_callback/app_page_cancel.py 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..9716d44893 --- /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="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("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("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..47cf27fe17 100644 --- a/tests/integration/long_callback/test_basic_long_callback.py +++ b/tests/integration/long_callback/test_basic_long_callback.py @@ -556,3 +556,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("#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("#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") From 050b85eea0c730a89d5e3d817957f662eb3be1ef Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 13 Jul 2023 15:25:59 -0400 Subject: [PATCH 4/9] Fix cancel tests. --- tests/integration/long_callback/test_basic_long_callback.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/long_callback/test_basic_long_callback.py b/tests/integration/long_callback/test_basic_long_callback.py index 47cf27fe17..9eb6ec8d40 100644 --- a/tests/integration/long_callback/test_basic_long_callback.py +++ b/tests/integration/long_callback/test_basic_long_callback.py @@ -63,6 +63,9 @@ def setup_long_callback_app(manager_name, app_name): ], preexec_fn=os.setpgrp, ) + # Wait for the worker to be ready, if you cancel before it is ready, the job + # will still be queued. + time.sleep(1) try: yield import_app(f"tests.integration.long_callback.{app_name}") finally: From 3466998e648baf4637f816b5e58bfa0c3a337000 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 14 Jul 2023 10:40:55 -0400 Subject: [PATCH 5/9] Wait for worker to be ready. --- tests/integration/long_callback/test_basic_long_callback.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/long_callback/test_basic_long_callback.py b/tests/integration/long_callback/test_basic_long_callback.py index 9eb6ec8d40..ff4520fd9b 100644 --- a/tests/integration/long_callback/test_basic_long_callback.py +++ b/tests/integration/long_callback/test_basic_long_callback.py @@ -62,10 +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. - time.sleep(1) + 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: From bc38c137c106ac7ed5930eb940f680f97d6b5efd Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Jul 2023 11:22:03 -0400 Subject: [PATCH 6/9] Rename cancel -> shared_cancel --- tests/integration/long_callback/app_page_cancel.py | 6 +++--- tests/integration/long_callback/test_basic_long_callback.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/long_callback/app_page_cancel.py b/tests/integration/long_callback/app_page_cancel.py index 9716d44893..7ea1adebf8 100644 --- a/tests/integration/long_callback/app_page_cancel.py +++ b/tests/integration/long_callback/app_page_cancel.py @@ -19,7 +19,7 @@ [ dcc.Link("page1", "/"), dcc.Link("page2", "/2"), - html.Button("Cancel", id="cancel"), + html.Button("Cancel", id="shared_cancel"), page_container, ] ) @@ -59,7 +59,7 @@ ], cancel=[ Input("cancel1", "n_clicks"), - Input("cancel", "n_clicks"), + Input("shared_cancel", "n_clicks"), ], background=True, prevent_initial_call=True, @@ -78,7 +78,7 @@ def on_click1(n_clicks): ], cancel=[ Input("cancel2", "n_clicks"), - Input("cancel", "n_clicks"), + Input("shared_cancel", "n_clicks"), ], background=True, prevent_initial_call=True, diff --git a/tests/integration/long_callback/test_basic_long_callback.py b/tests/integration/long_callback/test_basic_long_callback.py index ff4520fd9b..fc19d3c0af 100644 --- a/tests/integration/long_callback/test_basic_long_callback.py +++ b/tests/integration/long_callback/test_basic_long_callback.py @@ -570,7 +570,7 @@ def test_lcbc016_multi_page_cancel(dash_duo, manager): dash_duo.start_server(app) dash_duo.find_element("#start1").click() dash_duo.wait_for_text_to_equal("#progress1", "running") - dash_duo.find_element("#cancel").click() + 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") @@ -586,7 +586,7 @@ def test_lcbc016_multi_page_cancel(dash_duo, manager): dash_duo.find_element("#start2").click() dash_duo.wait_for_text_to_equal("#progress2", "running") - dash_duo.find_element("#cancel").click() + 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") From da66cb1bca205bce4fa43b10f329c1d39ad2e5f5 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Jul 2023 11:25:08 -0400 Subject: [PATCH 7/9] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 1974bf1d9403f3dfe3dcb21a7a5c0894d477034f Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Jul 2023 12:38:39 -0400 Subject: [PATCH 8/9] build From 909e39cad3041ded960432d29fa493e34cd90dab Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Jul 2023 14:07:55 -0400 Subject: [PATCH 9/9] build