Skip to content

bug in pattern-matching background callbacks #3169

Open
@BSd3v

Description

@BSd3v
dash                 2.18.2

When using Pattern-Matching callbacks that use background callbacks, the current way things work. If the output is the "same", (same exact callback), it will cancel the first callback and start the new one.

Here is an example of three buttons designed to use pattern-matching to update the Divs above once the task is complete, however, the task never completes if you trigger another button push within the time of the first push:

import dash
from dash import dcc, html, Input, Output, ctx, MATCH, DiskcacheManager, no_update
import time

import diskcache
cache = diskcache.Cache("./cache")

background_callback_manager = DiskcacheManager(cache)

# Initialize the Dash app
app = dash.Dash(__name__, background_callback_manager=background_callback_manager)

# Define the layout of the app
app.layout = html.Div([
    html.Div(id='progress-container', children=[
        *[html.Div(id={'type': 'progress', 'index': i}, children=f'{i}') for i in range(3)],
        *[html.Button(id={'type': 'button', 'index': i}, children=f'trigger-{i}') for i in range(3)]
    ]),
    html.Div(id='test-out'),
    html.Button(id='download_test', children='Download Test'),
    dcc.Download(id='download_file')
])

# Pattern-matching callback to update progress
@app.callback(
    Output({'type': 'progress', 'index': MATCH}, 'children'),
    Input({'type': 'button', 'index': MATCH}, 'n_clicks'),
    progress=[Output('test-out', 'children')],
    background=True,
    prevent_initial_call=True
)
def update_progress(set_progress, n):
    if n:
        count = 0
        while count < 100:
            count += 10
            set_progress(f'{ctx.triggered_id.index} - {count}')
            time.sleep(1)
        # Read the existing data from the file
        try:
            with open('test.txt', 'r') as f:
                data = f.readlines()
        except:
            data = []

        # Append the new line to the data
        data.append(f'{ctx.triggered_id.index} - {n}\n')

        # Write the updated data back to the file
        with open('test.txt', 'w') as f:
            f.writelines(data)
        return 'Task Complete!'
    return no_update

@app.callback(
    Output('download_file', 'data'),
    Input('download_test', 'n_clicks'),
    prevent_initial_call=True
)
def downfile(n):
    if n:
        with open('test.txt', 'r') as f:
            data = f.read()  # Read the entire file content
        return dcc.send_bytes(data.encode(), filename='test.txt')
    return no_update


# Run the app
if __name__ == '__main__':
    app.run_server(debug=True)

It also doesnt just stop polling for the response from the server, it also completely drops the function call. This is dangerous, especially when dealing with processing in the background that need to complete.

Using the above example, I clicked all three buttons but only the third completed the task fully:

Image


Here is the process that cancels the background running task, maybe we could allow for opting out of this check:

if (cb.callback.output === job.output) {
// Terminate the old jobs that are not completed
// set as outdated for the callback promise to
// resolve and remove after.
additionalArgs.push(['oldJob', job.jobId, true]);
dispatch(
setCallbackJobOutdated({jobId: job.jobId})
);
}

Metadata

Metadata

Assignees

Labels

P2considered for next cyclebugsomething brokencommunitycommunity contribution

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions