Skip to content

Commit 54e0bc3

Browse files
authored
Merge pull request #2344 from plotly/1519-callbacks-waiting-fix
1519 callbacks waiting fix
2 parents f096e1a + 2cca167 commit 54e0bc3

File tree

4 files changed

+163
-6
lines changed

4 files changed

+163
-6
lines changed

CHANGELOG.md

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

77
### Fixed
88

9+
- [#2344](https://github.com/plotly/dash/pull/2344) Fix [#1519](https://github.com/plotly/dash/issues/1519), a case where dependent callbacks can be called too many times and with inconsistent inputs
910
- [#2332](https://github.com/plotly/dash/pull/2332) Add key to wrapped children props in list.
1011
- [#2336](https://github.com/plotly/dash/pull/2336) Fix inserted dynamic ids in component as props.
1112

dash/dash-renderer/src/actions/dependencies_ts.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,52 @@ export function getPriority(
145145
return map(i => Math.min(i, 35).toString(36), priority).join('');
146146
}
147147

148+
export function getAllSubsequentOutputsForCallback(
149+
graphs: any,
150+
paths: any,
151+
callback: ICallback
152+
) {
153+
let callbacks: ICallback[] = [callback];
154+
let touchedOutputs: {[key: string]: boolean} = {};
155+
156+
// this traverses the graph all the way to the end
157+
while (callbacks.length) {
158+
// don't add it if it already exists based on id and props
159+
const outputs = filter(
160+
o => !touchedOutputs[combineIdAndProp(o)],
161+
flatten(map(cb => flatten(cb.getOutputs(paths)), callbacks))
162+
);
163+
164+
touchedOutputs = reduce(
165+
(touched, o) => assoc(combineIdAndProp(o), true, touched),
166+
touchedOutputs,
167+
outputs
168+
);
169+
170+
callbacks = flatten(
171+
map(
172+
({id, property}: any) =>
173+
getCallbacksByInput(
174+
graphs,
175+
paths,
176+
id,
177+
property,
178+
INDIRECT,
179+
false
180+
),
181+
outputs
182+
)
183+
);
184+
}
185+
186+
return touchedOutputs;
187+
}
188+
148189
export const getReadyCallbacks = (
149190
paths: any,
150191
candidates: ICallback[],
151-
callbacks: ICallback[] = candidates
192+
callbacks: ICallback[] = candidates,
193+
graphs: any = {}
152194
): ICallback[] => {
153195
// Skip if there's no candidates
154196
if (!candidates.length) {
@@ -166,9 +208,31 @@ export const getReadyCallbacks = (
166208
);
167209

168210
// Make `outputs` hash table for faster access
169-
const outputsMap: {[key: string]: boolean} = {};
211+
let outputsMap: {[key: string]: boolean} = {};
170212
forEach(output => (outputsMap[output] = true), outputs);
171213

214+
// find all the outputs touched by activeCallbacks
215+
// remove this check if graph is accessible all the time
216+
217+
if (Object.keys(graphs).length) {
218+
//not sure if graph will be accessible all the time
219+
const allTouchedOutputs: {[key: string]: boolean}[] = flatten(
220+
map(
221+
cb => getAllSubsequentOutputsForCallback(graphs, paths, cb),
222+
callbacks
223+
)
224+
);
225+
226+
// overrrides the outputsMap, will duplicate callbacks filtered
227+
// this is only done to silence typescript errors
228+
if (allTouchedOutputs.length > 0) {
229+
outputsMap = Object.assign(
230+
allTouchedOutputs[0],
231+
...allTouchedOutputs
232+
);
233+
}
234+
}
235+
172236
// Find `requested` callbacks that do not depend on a outstanding output (as either input or state)
173237
// Outputs which overlap an input do not count as an outstanding output
174238
return filter(

dash/dash-renderer/src/observers/requestedCallbacks.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ const observer: IStoreObserverDefinition<IStoreState> = {
6262
const {
6363
callbacks,
6464
callbacks: {prioritized, blocked, executing, watched, stored},
65-
paths
65+
paths,
66+
graphs
6667
} = getState();
6768
let {
6869
callbacks: {requested}
@@ -234,7 +235,8 @@ const observer: IStoreObserverDefinition<IStoreState> = {
234235
let readyCallbacks = getReadyCallbacks(
235236
paths,
236237
requested,
237-
pendingCallbacks
238+
pendingCallbacks,
239+
graphs
238240
);
239241

240242
let oldBlocked: ICallback[] = [];

tests/integration/callbacks/test_multiple_callbacks.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import time
2-
from multiprocessing import Value
2+
from multiprocessing import Value, Lock
33

44
import pytest
55

66
from dash import Dash, Input, Output, State, callback_context, html, dcc, dash_table
77
from dash.exceptions import PreventUpdate
88

9+
import dash.testing.wait as wait
10+
911

1012
def test_cbmt001_called_multiple_times_and_out_of_order(dash_duo):
1113
app = Dash(__name__)
@@ -17,7 +19,7 @@ def test_cbmt001_called_multiple_times_and_out_of_order(dash_duo):
1719

1820
@app.callback(Output("output", "children"), [Input("input", "n_clicks")])
1921
def update_output(n_clicks):
20-
call_count.value = call_count.value + 1
22+
call_count.value += 1
2123
if n_clicks == 1:
2224
time.sleep(1)
2325
return n_clicks
@@ -578,3 +580,91 @@ def callback(*args):
578580
assert call_counts[outputid].value == 1
579581

580582
assert call_counts["container"].value == (1 if generate else 0)
583+
584+
585+
def test_cbmt013_chained_callback_should_be_blocked(dash_duo):
586+
all_options = {
587+
"America": ["New York City", "San Francisco", "Cincinnati"],
588+
"Canada": ["Montreal", "Toronto", "Ottawa"],
589+
}
590+
591+
app = Dash(__name__)
592+
app.layout = html.Div(
593+
[
594+
dcc.RadioItems(
595+
id="countries-radio",
596+
options=[{"label": k, "value": k} for k in all_options.keys()],
597+
value="America",
598+
),
599+
html.Hr(),
600+
dcc.RadioItems(id="cities-radio"),
601+
html.Hr(),
602+
html.Div(id="display-selected-values"),
603+
]
604+
)
605+
606+
opts_call_count = Value("i", 0)
607+
city_call_count = Value("i", 0)
608+
out_call_count = Value("i", 0)
609+
out_lock = Lock()
610+
611+
@app.callback(Output("cities-radio", "options"), Input("countries-radio", "value"))
612+
def set_cities_options(selected_country):
613+
opts_call_count.value += 1
614+
return [{"label": i, "value": i} for i in all_options[selected_country]]
615+
616+
@app.callback(Output("cities-radio", "value"), Input("cities-radio", "options"))
617+
def set_cities_value(available_options):
618+
city_call_count.value += 1
619+
return available_options[0]["value"]
620+
621+
@app.callback(
622+
Output("display-selected-values", "children"),
623+
Input("countries-radio", "value"),
624+
Input("cities-radio", "value"),
625+
)
626+
def set_display_children(selected_country, selected_city):
627+
# this may actually be the key to this whole test:
628+
# these inputs should never be out of sync.
629+
assert selected_city in all_options[selected_country]
630+
631+
out_call_count.value += 1
632+
with out_lock:
633+
return "{} is a city in {}".format(
634+
selected_city,
635+
selected_country,
636+
)
637+
638+
dash_duo.start_server(app)
639+
640+
new_york_text = "New York City is a city in America"
641+
canada_text = "Montreal is a city in Canada"
642+
643+
# If we get to the correct initial state with only one call of each callback,
644+
# then there mustn't have been any intermediate changes to the output text
645+
dash_duo.wait_for_text_to_equal("#display-selected-values", new_york_text)
646+
assert opts_call_count.value == 1
647+
assert city_call_count.value == 1
648+
assert out_call_count.value == 1
649+
650+
all_labels = dash_duo.find_elements("label")
651+
canada_opt = next(
652+
i for i in all_labels if i.text == "Canada"
653+
).find_element_by_tag_name("input")
654+
655+
with out_lock:
656+
canada_opt.click()
657+
658+
# all three callbacks have fired once more, but since we haven't allowed the
659+
# last one to execute, the output hasn't been changed
660+
wait.until(lambda: out_call_count.value == 2, timeout=3)
661+
assert opts_call_count.value == 2
662+
assert city_call_count.value == 2
663+
assert dash_duo.find_element("#display-selected-values").text == new_york_text
664+
665+
dash_duo.wait_for_text_to_equal("#display-selected-values", canada_text)
666+
assert opts_call_count.value == 2
667+
assert city_call_count.value == 2
668+
assert out_call_count.value == 2
669+
670+
assert dash_duo.get_logs() == []

0 commit comments

Comments
 (0)