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

[BUG] Breaking change for chained callbacks in dash>=2.9.0 #2767

Open
celia-lm opened this issue Feb 21, 2024 · 5 comments
Open

[BUG] Breaking change for chained callbacks in dash>=2.9.0 #2767

celia-lm opened this issue Feb 21, 2024 · 5 comments
Labels

Comments

@celia-lm
Copy link

celia-lm commented Feb 21, 2024

Describe the bug

"Click" button is the Input for two callbacks. When we click it, both should be triggered:

  • the first one takes a long time (e.g. 3 seconds), and when it ends, it should update the "stop" button, which triggers the second callback too.
  • the second callback returns quickly, it should return a "Progress" notification with the first trigger (when the "click" button triggers it) and a "Complete" notification when the "Stop" button triggers it - after the first callbacks finishes running.
  • So these are chained callbacks apart from the "Click" button being an Input for both.
  • Bug: we only see the "Progress" notification and after 3 seconds, when the first callbacks finishes running. The second callback is never triggered by the "Stop" button update as a result of the chained callback (we can see this by checking the triggered ids).
Screen.Recording.2024-02-21.at.17.28.05.mov

The developer who reported this experienced after upgrading from dash==2.6.2 to dash==2.15.0.

Hypothesis: this might be due to the 2.9.0 changes to accommodate duplicate outputs.

Code to reproduce the issue:

from dash import Output, Input, html, dcc, ctx, callback
from dash_iconify import DashIconify
import dash_mantine_components as dmc
import time
import dash
import random

app = dash.Dash(__name__)
app.layout =dmc.NotificationsProvider( html.Div([
    html.Button("click", id="btn-start"),
    html.Button("stop", id="btn-stop"), #style={'display': 'none'}),
    html.Div(id="notify-container"),
    ]
))

@callback(
    Output("btn-stop", "n_clicks"),
    Input("btn-start", "n_clicks"),
)
def make_api_call(nc1):
    # print("CALLBACK 1")
    changed_id = [p['prop_id'] for p in ctx.triggered][0]
    if "btn-start" in changed_id:
        # making api call
        print("api call")
        time.sleep(3)
        return 1 
    else :
        return dash.no_update   

@callback(
    Output("notify-container", "children"),
    Input("btn-start", "n_clicks"),
    Input("btn-stop", "n_clicks"),
    prevent_initial_call=True,
)
def notify(nc1, nc2):
    # print("CALLBACK 2")
    button_id = [p['prop_id'] for p in dash.callback_context.triggered][0]
    if "start" in button_id:
        return dmc.Notification(
            id="my-notification",
            title="Process initiated",
            message="The process has started.",
            loading=True,
            color="orange",
            action="show",
            autoClose=False,
            disallowClose=True,
        )
    elif "stop" in button_id:
        return dmc.Notification(
            id="my-notification",
            title="Data loaded",
            message="The process has started.",
            color="green",
            #action="show",
            action="update",
            icon=DashIconify(icon="akar-icons:circle-check"),
        )
    else : 
        return dash.no_update

if __name__ == '__main__':
    app.run(debug=True)

Expected behavior

"Click" button should trigger both callbacks at the same time, which would result in "Progress" notification showing immediately and lasting 3 seconds, and then "Complete" notification replacing it:

Screen.Recording.2024-02-21.at.17.22.04.mov

This behaviour can be reproduced with dash>=2.9.x if we use duplicate outputs, but for users this would mean rewriting several callbacks, as this would be a breaking change otherwise:

from dash import Output, Input, html, callback_context as ctx, callback
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import time
import dash
import random

app = dash.Dash(__name__)
app.layout =dmc.NotificationsProvider( html.Div([
    html.Button("click", id="btn-start"),
    html.Button("stop", id="btn-stop"), #style={'display': 'none'}),
    html.Div(id="notify-container"),
    ]
))

@callback(
    Output("btn-stop", "n_clicks"),
    Input("btn-start", "n_clicks"),
    prevent_initial_call=True
)
def make_api_call(nc1):
    print("CALLBACK 1")
    changed_id = ctx.triggered_id
    if "btn-start" in changed_id:
        # making api call
        print("api call")
        time.sleep(3)
        return 1 
    else :
        return dash.no_update   

@callback(
    Output("notify-container", "children", allow_duplicate=True),
    Input("btn-start", "n_clicks"),
    prevent_initial_call=True,
)
def notify(nc1):
    print("CALLBACK 2")
    button_id = ctx.triggered_id
    if "start" in button_id:
        return dmc.Notification(
            id="my-notification",
            title="Process initiated",
            message="The process has started.",
            loading=True,
            color="orange",
            action="show",
            autoClose=False,
            disallowClose=True,
        )
    else : 
        return dash.no_update

@callback(
    Output("notify-container", "children"),
    Input("btn-stop", "n_clicks"),
    prevent_initial_call=True,
)
def notify(nc1):
    print("CALLBACK 3")
    button_id = ctx.triggered_id
    if "stop" in button_id:
        return dmc.Notification(
            id="my-notification",
            title="Data loaded",
            message="The process has started.",
            color="green",
            #action="show",
            action="update",
            icon=DashIconify(icon="akar-icons:circle-check"),
        )
    else : 
        return dash.no_update

if __name__ == '__main__':
    app.run(debug=True)

Describe your context

  • replace the result of pip list | grep dash below
dash==2.15.0 # 2.6.2
dash-ag-grid==1.2.1
dash-bootstrap-components==1.0.3
dash-colorscales==0.0.4
dash-core-components==2.0.0
dash-cron==0.0.1
dash-dangerously-set-inner-html==0.0.2
dash-daq==0.5.0
dash-design-kit==1.6.7
dash-draggable==0.1.2
dash-embedded==2.0.0
dash-enterprise-auth==0.0.5
dash-html-components==2.0.0
dash-iconify==0.1.2
dash-mantine-components==0.12.1
dash-notes==0.0.3
dash-snapshots==1.4.6
dash-split-pane==1.0.0
dash-table==5.0.0
@Lew-Goldstein
Copy link

Lew-Goldstein commented Feb 21, 2024

Hi @nicolearksey1, reaching out on this bug report as a DE customer is expecting a quick turnaround, i.e. 2 days. I know this is unrealistic, but wanted to make sure this is on your radar. Might we be able to accelerate this fix to some reasonable time frame and provide them an SLA for a patch release.

cc: @mjainGH

@celia-lm
Copy link
Author

@michaelbabyn found that this change could be the cause: #1519 (this was merged in 2.7.1:

- [#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
)

@michaelbabyn
Copy link

I modified Celia's example slightly so that it reproduces the desired behaviour in 2.7.0 but it breaks in 2.7.1

from dash import Output, Input, html, dcc, ctx, callback
from dash_iconify import DashIconify
import dash_mantine_components as dmc
import time
import dash
import random

app = dash.Dash(__name__)
app.layout = dmc.NotificationsProvider(
    html.Div(
        [
            html.Button("click", id="btn-start"),
            html.Button("stop", id="btn-stop"),  # style={'display': 'none'}),
            html.Div(id="notify-container"),
            html.Div(id="intermediate-value", style={"display": "none"}),
        ]
    )
)

# start button updates intermediate value
@callback(
    Output("intermediate-value", "children"),
    Input("btn-start", "n_clicks"),
    prevent_initial_call=True,
)
def start_process(n_clicks):
    print("CALLBACK 0")
    return n_clicks


@callback(
    Output("btn-stop", "n_clicks"),
    Input("intermediate-value", "children"),
)
def make_api_call(nc1):
    # print("CALLBACK 1")
    # print(f"nc1: {nc1}")
    changed_id = [p["prop_id"] for p in ctx.triggered][0]
    if "intermediate" in changed_id:
        # making api call
        print("api call")
        time.sleep(3)
        return nc1
    # else :
    #     return dash.no_update


@callback(
    Output("notify-container", "children"),
    Input("btn-start", "n_clicks"),
    Input("btn-stop", "n_clicks"),
    # prevent_initial_call=True,
)
def notify(nc1, nc2):
    # print("CALLBACK 2")
    button_id = [p["prop_id"] for p in dash.callback_context.triggered][0]
    if "start" in button_id:
        return dmc.Notification(
            id="my-notification",
            title="Process initiated",
            message="The process has started.",
            loading=True,
            color="orange",
            action="show",
            autoClose=False,
            disallowClose=True,
        )
    elif "stop" in button_id:
        return dmc.Notification(
            id="my-notification",
            title="Data loaded",
            message="The process has started.",
            color="green",
            # action="show",
            action="update",
            icon=DashIconify(icon="akar-icons:circle-check"),
        )
    else:
        return dash.no_update


if __name__ == "__main__":
    app.run(debug=True, port=8021)

@Lew-Goldstein
Copy link

See Slack thread for additional details.

@AnnMarieW
Copy link
Collaborator

@celia-lm Is this issue closed by using the callback running arg available in Dash 2.16?
https://dash.plotly.com/advanced-callbacks#updating-component-properties-when-a-callback-is-running

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants