Skip to content

Commit 3c43d95

Browse files
authored
Merge pull request #2599 from plotly/fix/#2588
Create a single cancel callback for each unique input.
2 parents 1718ec0 + 909e39c commit 3c43d95

File tree

5 files changed

+173
-23
lines changed

5 files changed

+173
-23
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
77
## Fixed
88

99
- [#2589](https://github.com/plotly/dash/pull/2589) CSS for input elements not scoped to Dash application
10+
- [#2599](https://github.com/plotly/dash/pull/2599) Fix background callback cancel inputs used in multiple callbacks and mixed cancel inputs across pages.
1011

1112
## Changed
1213

dash/_callback.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from .exceptions import (
1313
PreventUpdate,
1414
WildcardInLongCallback,
15-
DuplicateCallback,
1615
MissingLongCallbackManagerError,
1716
LongCallbackError,
1817
)
@@ -171,25 +170,8 @@ def callback(
171170
cancel_inputs = coerce_to_list(cancel)
172171
validate_long_inputs(cancel_inputs)
173172

174-
cancels_output = [Output(c.component_id, "id") for c in cancel_inputs]
175-
176-
try:
177-
178-
@callback(cancels_output, cancel_inputs, prevent_initial_call=True)
179-
def cancel_call(*_):
180-
job_ids = flask.request.args.getlist("cancelJob")
181-
executor = (
182-
manager or context_value.get().background_callback_manager
183-
)
184-
if job_ids:
185-
for job_id in job_ids:
186-
executor.terminate_job(job_id)
187-
return NoUpdate()
188-
189-
except DuplicateCallback:
190-
pass # Already a callback to cancel, will get the proper jobs from the store.
191-
192173
long_spec["cancel"] = [c.to_dict() for c in cancel_inputs]
174+
long_spec["cancel_inputs"] = cancel_inputs
193175

194176
if cache_args_to_ignore:
195177
long_spec["cache_args_to_ignore"] = cache_args_to_ignore
@@ -201,6 +183,7 @@ def cancel_call(*_):
201183
*_args,
202184
**_kwargs,
203185
long=long_spec,
186+
manager=manager,
204187
)
205188

206189

@@ -238,6 +221,7 @@ def insert_callback(
238221
inputs_state_indices,
239222
prevent_initial_call,
240223
long=None,
224+
manager=None,
241225
):
242226
if prevent_initial_call is None:
243227
prevent_initial_call = config_prevent_initial_callbacks
@@ -269,6 +253,7 @@ def insert_callback(
269253
"long": long,
270254
"output": output,
271255
"raw_inputs": inputs,
256+
"manager": manager,
272257
}
273258
callback_list.append(callback_spec)
274259

@@ -296,6 +281,7 @@ def register_callback( # pylint: disable=R0914
296281
multi = True
297282

298283
long = _kwargs.get("long")
284+
manager = _kwargs.get("manager")
299285

300286
output_indices = make_grouping_by_index(output, list(range(grouping_len(output))))
301287
callback_id = insert_callback(
@@ -309,6 +295,7 @@ def register_callback( # pylint: disable=R0914
309295
inputs_state_indices,
310296
prevent_initial_call,
311297
long=long,
298+
manager=manager,
312299
)
313300

314301
# pylint: disable=too-many-locals

dash/dash.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,9 +1194,6 @@ def dispatch(self):
11941194
input_values
11951195
) = inputs_to_dict(inputs)
11961196
g.state_values = inputs_to_dict(state) # pylint: disable=assigning-non-slot
1197-
g.background_callback_manager = (
1198-
self._background_manager
1199-
) # pylint: disable=E0237
12001197
changed_props = body.get("changedPropIds", [])
12011198
g.triggered_inputs = [ # pylint: disable=assigning-non-slot
12021199
{"prop_id": x, "value": input_values.get(x)} for x in changed_props
@@ -1211,7 +1208,9 @@ def dispatch(self):
12111208
try:
12121209
cb = self.callback_map[output]
12131210
func = cb["callback"]
1214-
1211+
g.background_callback_manager = (
1212+
cb.get("manager") or self._background_manager
1213+
)
12151214
g.ignore_register_page = cb.get("long", False)
12161215

12171216
# Add args_grouping
@@ -1316,6 +1315,35 @@ def _setup_server(self):
13161315

13171316
_validate.validate_long_callbacks(self.callback_map)
13181317

1318+
cancels = {}
1319+
1320+
for callback in self.callback_map.values():
1321+
long = callback.get("long")
1322+
if not long:
1323+
continue
1324+
cancel = long.pop("cancel_inputs")
1325+
if cancel:
1326+
for c in cancel:
1327+
cancels[c] = long.get("manager")
1328+
1329+
if cancels:
1330+
for cancel_input, manager in cancels.items():
1331+
1332+
# pylint: disable=cell-var-from-loop
1333+
@self.callback(
1334+
Output(cancel_input.component_id, "id"),
1335+
cancel_input,
1336+
prevent_initial_call=True,
1337+
manager=manager,
1338+
)
1339+
def cancel_call(*_):
1340+
job_ids = flask.request.args.getlist("cancelJob")
1341+
executor = _callback.context_value.get().background_callback_manager
1342+
if job_ids:
1343+
for job_id in job_ids:
1344+
executor.terminate_job(job_id)
1345+
return no_update
1346+
13191347
def _add_assets_resource(self, url_path, file_path):
13201348
res = {"asset_path": url_path, "filepath": file_path}
13211349
if self.config.assets_external_path:
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from dash import Dash, Input, Output, dcc, html, page_container, register_page
2+
3+
import time
4+
5+
from tests.integration.long_callback.utils import get_long_callback_manager
6+
7+
long_callback_manager = get_long_callback_manager()
8+
handle = long_callback_manager.handle
9+
10+
11+
app = Dash(
12+
__name__,
13+
use_pages=True,
14+
pages_folder="",
15+
long_callback_manager=long_callback_manager,
16+
)
17+
18+
app.layout = html.Div(
19+
[
20+
dcc.Link("page1", "/"),
21+
dcc.Link("page2", "/2"),
22+
html.Button("Cancel", id="shared_cancel"),
23+
page_container,
24+
]
25+
)
26+
27+
28+
register_page(
29+
"one",
30+
"/",
31+
layout=html.Div(
32+
[
33+
html.Button("start", id="start1"),
34+
html.Button("cancel1", id="cancel1"),
35+
html.Div("idle", id="progress1"),
36+
html.Div("initial", id="output1"),
37+
]
38+
),
39+
)
40+
register_page(
41+
"two",
42+
"/2",
43+
layout=html.Div(
44+
[
45+
html.Button("start2", id="start2"),
46+
html.Button("cancel2", id="cancel2"),
47+
html.Div("idle", id="progress2"),
48+
html.Div("initial", id="output2"),
49+
]
50+
),
51+
)
52+
53+
54+
@app.callback(
55+
Output("output1", "children"),
56+
Input("start1", "n_clicks"),
57+
running=[
58+
(Output("progress1", "children"), "running", "idle"),
59+
],
60+
cancel=[
61+
Input("cancel1", "n_clicks"),
62+
Input("shared_cancel", "n_clicks"),
63+
],
64+
background=True,
65+
prevent_initial_call=True,
66+
interval=300,
67+
)
68+
def on_click1(n_clicks):
69+
time.sleep(2)
70+
return f"Click {n_clicks}"
71+
72+
73+
@app.callback(
74+
Output("output2", "children"),
75+
Input("start2", "n_clicks"),
76+
running=[
77+
(Output("progress2", "children"), "running", "idle"),
78+
],
79+
cancel=[
80+
Input("cancel2", "n_clicks"),
81+
Input("shared_cancel", "n_clicks"),
82+
],
83+
background=True,
84+
prevent_initial_call=True,
85+
interval=300,
86+
)
87+
def on_click1(n_clicks):
88+
time.sleep(2)
89+
return f"Click {n_clicks}"
90+
91+
92+
if __name__ == "__main__":
93+
app.run(debug=True)

tests/integration/long_callback/test_basic_long_callback.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@ def setup_long_callback_app(manager_name, app_name):
6262
"--loglevel=info",
6363
],
6464
preexec_fn=os.setpgrp,
65+
stderr=subprocess.PIPE,
6566
)
67+
# Wait for the worker to be ready, if you cancel before it is ready, the job
68+
# will still be queued.
69+
for line in iter(worker.stderr.readline, ""):
70+
if "ready" in line.decode():
71+
break
72+
6673
try:
6774
yield import_app(f"tests.integration.long_callback.{app_name}")
6875
finally:
@@ -556,3 +563,37 @@ def test_lcbc015_diff_outputs_same_func(dash_duo, manager):
556563
for i in range(1, 3):
557564
dash_duo.find_element(f"#button-{i}").click()
558565
dash_duo.wait_for_text_to_equal(f"#output-{i}", f"Clicked on {i}")
566+
567+
568+
def test_lcbc016_multi_page_cancel(dash_duo, manager):
569+
with setup_long_callback_app(manager, "app_page_cancel") as app:
570+
dash_duo.start_server(app)
571+
dash_duo.find_element("#start1").click()
572+
dash_duo.wait_for_text_to_equal("#progress1", "running")
573+
dash_duo.find_element("#shared_cancel").click()
574+
dash_duo.wait_for_text_to_equal("#progress1", "idle")
575+
time.sleep(2.1)
576+
dash_duo.wait_for_text_to_equal("#output1", "initial")
577+
578+
dash_duo.find_element("#start1").click()
579+
dash_duo.wait_for_text_to_equal("#progress1", "running")
580+
dash_duo.find_element("#cancel1").click()
581+
dash_duo.wait_for_text_to_equal("#progress1", "idle")
582+
time.sleep(2.1)
583+
dash_duo.wait_for_text_to_equal("#output1", "initial")
584+
585+
dash_duo.server_url = dash_duo.server_url + "/2"
586+
587+
dash_duo.find_element("#start2").click()
588+
dash_duo.wait_for_text_to_equal("#progress2", "running")
589+
dash_duo.find_element("#shared_cancel").click()
590+
dash_duo.wait_for_text_to_equal("#progress2", "idle")
591+
time.sleep(2.1)
592+
dash_duo.wait_for_text_to_equal("#output2", "initial")
593+
594+
dash_duo.find_element("#start2").click()
595+
dash_duo.wait_for_text_to_equal("#progress2", "running")
596+
dash_duo.find_element("#cancel2").click()
597+
dash_duo.wait_for_text_to_equal("#progress2", "idle")
598+
time.sleep(2.1)
599+
dash_duo.wait_for_text_to_equal("#output2", "initial")

0 commit comments

Comments
 (0)