Callback Not Triggered by Diamond Shaped Dash Input Pattern, Feature or Bug?

Hi, I am still new to the dash community, so this might be a stupid question to begin with…

I am puzzled by a bug in my dash app and I was able to distill the issue to this: suppose I have a timer (interval component using dash language) that have two paths going forward, like a diamond shape ( starting from a single timer, down different paths and converge to a single component):

Case 1:
A -> B -> D
and
A -> C -> D
where A is interval component and B/C/D are some components that are updated based on trigger from A.

Everything is fine. And if I break the link A -> C -> D by throwing PreventUpdate inside C, then callback on D is still triggered because there is another route A -> B -> D.

All fine so far.

Now if I add a little extra to the picture:
Case 2:
A -> B -> D
and
A -> C -> C2 -> D
and instead of throwing inside C, I throw PreventUpdate inside C2, then D is NOT Triggered anymore.

The code for the example and output are attached. Can someone explain the logic behind this, or am I simply lucky enough to hit a bug? This solution I have so far is this: instead of using single timer to trigger both routes, I used another trigger A2 to trigger the second path, it works but I still would like to find out if there is a logic behind the fact that Case 1 works but Case 2 does not.

thanks!

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import *

app = dash.Dash()

app.layout = html.Div([
    dcc.Interval(id='A', interval=3000),
    html.Div(id='B'),
    html.Div(id='C'),
    html.Div(id='C2'),
    html.Div(id='D')
])


@app.callback(
    Output('B', 'children'),
    [Input('A', 'n_intervals')]
)
def update_b(x):
    print('in B')
    return 'b'


n = 0


@app.callback(
    Output('C2', 'children'),
    [Input('C', 'children')]
)
def update_c2(c):
    print('in C2')
    global n
    n += 1
    if n % 2:
        print(n)
        raise dash.exceptions.PreventUpdate()
    print('pass')
    return 'c2'


@app.callback(
    Output('C', 'children'),
    [Input('A', 'n_intervals')]
)
def update_c(x):
    print('in C')
    return 'c'


@app.callback(
    Output('D', 'children'),
    [Input('B', 'children'),
     Input('C2', 'children')]
)
def update_d(b, c):
    print('in D')
    return 'd'

Console Output:

in C
127.0.0.1 - - [21/Sep/2018 21:17:00] "POST /_dash-update-component HTTP/1.1" 200 -
in B
127.0.0.1 - - [21/Sep/2018 21:17:01] "POST /_dash-update-component HTTP/1.1" 200 -
in C2
1

127.0.0.1 - - [21/Sep/2018 21:17:01] "POST /_dash-update-component HTTP/1.1" 204 -
in C
127.0.0.1 - - [21/Sep/2018 21:17:02] "POST /_dash-update-component HTTP/1.1" 200 -
in B
127.0.0.1 - - [21/Sep/2018 21:17:02] "POST /_dash-update-component HTTP/1.1" 200 -
in C2
pass
127.0.0.1 - - [21/Sep/2018 21:17:02] "POST /_dash-update-component HTTP/1.1" 200 -
in D
127.0.0.1 - - [21/Sep/2018 21:17:02] "POST /_dash-update-component HTTP/1.1" 200 -
in C
127.0.0.1 - - [21/Sep/2018 21:17:05] "POST /_dash-update-component HTTP/1.1" 200 -
in B
127.0.0.1 - - [21/Sep/2018 21:17:05] "POST /_dash-update-component HTTP/1.1" 200 -
in C2
3

127.0.0.1 - - [21/Sep/2018 21:17:05] "POST /_dash-update-component HTTP/1.1" 204 -
in C
127.0.0.1 - - [21/Sep/2018 21:17:08] "POST /_dash-update-component HTTP/1.1" 200 -
in B
127.0.0.1 - - [21/Sep/2018 21:17:08] "POST /_dash-update-component HTTP/1.1" 200 -
in C2
pass
127.0.0.1 - - [21/Sep/2018 21:17:08] "POST /_dash-update-component HTTP/1.1" 200 -
in D
127.0.0.1 - - [21/Sep/2018 21:17:08] "POST /_dash-update-component HTTP/1.1" 200 -

This seems inconsistent and so I’d consider this a bug. Intuitively, it seems like throwing PreventUpdate in one side of the chain shouldn’t update the other side of the chain (and if someone wanted it to update the other side of the chain then they could just make B an input of C or throw an PreventUpdate in B).

Thanks very much for reporting. We’ll look into this issue and, if it ends up being a bug, will fix it. Follow this issue for development updates: prevent update not breaking the chain of execution in this branched example? · Issue #731 · plotly/dash · GitHub

Hi chriddyp, thanks for your reply.

Just to clarify, throwing in one side of chain never updated the other side of the chain (B and C never interacted with each other). The issue is more of a policy question: If A has multiple paths(chains) to reach D, (via B or C, or even more other paths), then when should D be triggered? Policy 1. At least one of the paths is not blocked by PreventUpdate; Policy 2. Only when none of the paths are blocked.

Case 1 in the original post seems to be consistent with policy 1, while Case 2 is consistent with policy 2.

I have a theory of why this happens.

Below are lines of code taken out from dash-renderer repo. Inside the comments there is an example - A updates B & C, and B also updates C. Function notifyObservers is optimized such that A do not directly update C. Now suppose B throws PreventUpdate on server side, C will never get updated.

dash-renderer/tree/master/src/actions/index.js

            ...

export function notifyObservers(payload) {

            ...
            
            /*
             * before we make the POST to update the output, check
             * that the output doesn't depend on any other inputs that
             * that depend on the same controller.
             * if the output has another input with a shared controller,
             * then don't update this output yet.
             * when each dependency updates, it'll dispatch its own
             * `notifyObservers` action which will allow this
             * component to update.
             *
             * for example, if A updates B and C (A -> [B, C]) and B updates C
             * (B -> C), then when A updates, this logic will
             * reject C from the queue since it will end up getting updated
             * by B.
             *
             * in this case, B will already be in queuedObservers by the time
             * this loop hits C because of the overallOrder sorting logic
             */

            /*
              * if the output just listens to events, then it won't be in
              * the InputGraph
              */
            const controllers = InputGraph.hasNode(outputIdAndProp)
                ? InputGraph.dependantsOf(outputIdAndProp)
                : [];

            const controllersInFutureQueue = intersection(
                queuedObservers,
                controllers
            );
            
            ...
            
            if (
                controllersInFutureQueue.length === 0 &&
                has(outputComponentId, getState().paths) &&
                !controllerIsInExistingQueue
            ) {
                queuedObservers.push(outputIdAndProp);
            }