Last call to update `progress` from a `long_callback` is not reflected in UI

I have a long_callback that uses the progress named argument to send updates as the function runs. My progress-updating function is called set_progress. However, the final call to set_progress is not realized in the client/browser when the callback finishes.

The final Output of the callback is shown, as well as a print statement being visible on stdout even though the statement is after the last call to set_progress.

Interestingly enough, if I comment out the callback’s Output and the final return statement, then the effects of the final set_progress can be seen, but the page’s title keeps flashing that it’s loading/updating, which to me means the callback hasn’t registered as “complete” yet.

This leads me to suspect that the “completion” of the callback is happening too fast, or too soon after the last set_progress call, which is somehow nullifying its effects. If I have the Output added back in and add a time.sleep(1) just before the return statement, then the effects of the final progress update can be seen. However, this is a hacky solution and should not be needed in the first place.

Environment

OS

$ cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.3 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.3 LTS"
VERSION_ID="20.04"
...
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

Python version

$ python --version
Python 3.9.6

Dash packages

$ python -m pip list | grep dash
dash                      2.0.0
dash-bootstrap-components 0.13.1
dash-core-components      2.0.0
dash-html-components      2.0.0
dash-table                5.0.0

Relevant .py files

app.py

import dash
import dash_bootstrap_components as dbc

from website.layout_main import define_callbacks, layout
from website.long_callback_manager import LONG_CALLBACK_MANAGER


app = dash.Dash(
    __name__,
    update_title="Loading...",
    external_stylesheets=[
        dbc.themes.BOOTSTRAP,
        "https://codepen.io/chriddyp/pen/bWLwgP.css"
    ],
    long_callback_manager=LONG_CALLBACK_MANAGER
)

app.title = "CS 236 | Project Submissions"
app.layout = layout
define_callbacks(app)
server = app.server  # expose for gunicorn

if __name__ == "__main__":
    app.run_server(debug=True, host="0.0.0.0")

website/long_callback_manager.py

import os
import shutil

import diskcache
from dash.long_callback import DiskcacheLongCallbackManager

from util import RUN_DIR


cache_dir = os.path.join(RUN_DIR, "callback_cache")
shutil.rmtree(cache_dir, ignore_errors=True)  # ok if it didn't exist

cache = diskcache.Cache(cache_dir)

LONG_CALLBACK_MANAGER = DiskcacheLongCallbackManager(cache)

website/layout_main.py

from typing import Union

import dash
import dash_bootstrap_components as dbc
from dash import dcc, html
from dash.dependencies import Input, Output, State

from util.authenticator import authenticate
from website import ID_LOGIN_STORE, NET_ID, PASSWORD
from website.tabs.config import define_config_callbacks, layout as config_layout
from website.tabs.log import define_log_callbacks, layout as log_layout
from website.tabs.submit import define_submit_callbacks, layout as submit_layout
from website.util import AUTH_FAILED_MESSAGE, STYLE_RED


# cache
LOGIN_INFO_EMPTY = {NET_ID: None, PASSWORD: None}
# button display modes
VISIBLE = "inline-block"
HIDDEN = "none"

# header
ID_LOGIN_BUTTON = "login-button"
ID_LOGGED_IN_AS = "logged-in-as"
ID_LOGOUT_BUTTON = "logout-button"
# tabs
ID_TAB_SELECTOR = "tab-selector"
ID_SUBMIT_TAB = "submit-tab"
ID_LOG_TAB = "log-tab"
ID_CONFIG_TAB = "config-tab"
# login modal
ID_LOGIN_MODAL = "login-modal"
ID_LOGIN_MODAL_NET_ID = "login-modal-net-id"
ID_LOGIN_MODAL_PASSWORD = "login-modal-password"
ID_LOGIN_MODAL_MESSAGE = "login-modal-message"
ID_LOGIN_MODAL_CANCEL = "login-modal-cancel"
ID_LOGIN_MODAL_ACCEPT = "login-modal-accept"
# logout modal
ID_LOGOUT_MODAL = "logout-modal"
ID_LOGOUT_MODAL_CANCEL = "logout-modal-cancel"
ID_LOGOUT_MODAL_ACCEPT = "logout-modal-accept"


layout = html.Div([
    dcc.Store(id=ID_LOGIN_STORE, storage_type="session", data=LOGIN_INFO_EMPTY),
    html.Div(
        [
            html.H2("BYU CS 236 - Project Submission Website", style={"marginLeft": "10px"}),
            html.Div(
                [
                    html.Div(id=ID_LOGGED_IN_AS, style={"display": HIDDEN, "marginRight": "10px"}),
                    html.Button("Log in", id=ID_LOGIN_BUTTON, style={"display": VISIBLE}),
                    html.Button("Log out", id=ID_LOGOUT_BUTTON, style={"display": HIDDEN})
                ],
                style={
                    "marginRight": "25px",
                    "display": "flex",
                    "alignItems": "center"
                }
            )
        ],
        style={
            "height": "100px",
            "marginLeft": "10px",
            "marginRight": "10px",
            "display": "flex",
            "alignItems": "center",
            "justifyContent": "space-between"
        }
    ),
    dcc.Tabs(id=ID_TAB_SELECTOR, value=ID_SUBMIT_TAB, children=[
        dcc.Tab(submit_layout, label="New Submission", value=ID_SUBMIT_TAB),
        dcc.Tab(log_layout, label="Submission Logs", value=ID_LOG_TAB),
        dcc.Tab(config_layout, label="View Configuration", value=ID_CONFIG_TAB)
    ]),
    dbc.Modal(
        [
            dbc.ModalHeader("Log In"),
            dbc.ModalBody([
                html.Div(
                    [
                        html.Label("BYU Net ID:", style={"marginRight": "10px"}),
                        dcc.Input(
                            id=ID_LOGIN_MODAL_NET_ID,
                            type="text",
                            autoComplete="username",
                            value="",
                            style={"marginRight": "30px"}
                        )
                    ],
                    style={
                        "marginBottom": "5px",
                        "display": "flex",
                        "alignItems": "center",
                        "justifyContent": "flex-end"
                    }
                ),
                html.Div(
                    [
                        html.Label("Submission Password:", style={"marginRight": "10px"}),
                        dcc.Input(
                            id=ID_LOGIN_MODAL_PASSWORD,
                            type="password",
                            autoComplete="current-password",
                            value="",
                            style={"marginRight": "30px"}
                        )
                    ],
                    style={
                        "display": "flex",
                        "alignItems": "center",
                        "justifyContent": "flex-end"
                    }
                ),
                html.Div(id=ID_LOGIN_MODAL_MESSAGE, style={"textAlign": "center", "marginTop": "10px"})
            ]),
            dbc.ModalFooter([
                html.Button("Cancel", id=ID_LOGIN_MODAL_CANCEL),
                html.Button("Log In", id=ID_LOGIN_MODAL_ACCEPT)
            ])
        ],
        id=ID_LOGIN_MODAL,
        is_open=False
    ),
    dbc.Modal(
        [
            dbc.ModalHeader("Log Out"),
            dbc.ModalBody("Are you sure you want to log out?"),
            dbc.ModalFooter([
                html.Button("Stay Logged In", id=ID_LOGOUT_MODAL_CANCEL),
                html.Button("Log Out", id=ID_LOGOUT_MODAL_ACCEPT)
            ])
        ],
        id=ID_LOGOUT_MODAL,
        is_open=False
    )
])


def on_click_login_modal_accept(net_id: Union[str, None], password: Union[str, None]) -> Union[str, None]:
    # validate
    if net_id is None or net_id == "":
        return "BYU Net ID is required."
    if password is None or password == "":
        return "Submission Password is required."
    # authenticate
    auth_success = authenticate(net_id, password)
    if auth_success:
        return None
    else:
        return AUTH_FAILED_MESSAGE


def define_callbacks(app: dash.Dash):
    @app.callback(Output(ID_LOGIN_MODAL, "is_open"),
                  Output(ID_LOGIN_MODAL_MESSAGE, "children"),
                  Output(ID_LOGOUT_MODAL, "is_open"),
                  Output(ID_LOGIN_STORE, "data"),
                  Input(ID_LOGIN_BUTTON, "n_clicks"),
                  Input(ID_LOGIN_MODAL_CANCEL, "n_clicks"),
                  Input(ID_LOGIN_MODAL_ACCEPT, "n_clicks"),
                  Input(ID_LOGOUT_BUTTON, "n_clicks"),
                  Input(ID_LOGOUT_MODAL_CANCEL, "n_clicks"),
                  Input(ID_LOGOUT_MODAL_ACCEPT, "n_clicks"),
                  State(ID_LOGIN_MODAL_NET_ID, "value"),
                  State(ID_LOGIN_MODAL_PASSWORD, "value"),
                  prevent_initial_call=True)
    def on_login_logout_clicked(
            n_login_clicks: int,
            n_login_cancel_clicks: int,
            n_login_accept_clicks: int,
            n_logout_clicks: int,
            n_logout_cancel_clicks: int,
            n_logout_accept_clicks: int,
            net_id: str,
            password: str):
        ctx = dash.callback_context
        btn_id = ctx.triggered[0]["prop_id"].split(".")[0]
        if btn_id == ID_LOGIN_BUTTON:
            # show the login modal (with no message)
            return True, None, dash.no_update, dash.no_update
        elif btn_id == ID_LOGIN_MODAL_CANCEL:
            # hide the login modal
            return False, dash.no_update, dash.no_update, dash.no_update
        elif btn_id == ID_LOGIN_MODAL_ACCEPT:
            # try to actually log in
            error_message = on_click_login_modal_accept(net_id, password)
            if error_message is None:  # login success!
                # hide the modal, update the login store
                return False, dash.no_update, dash.no_update, {NET_ID: net_id, PASSWORD: password}
            else:  # login failed
                # show the message and keep the modal open
                return dash.no_update, html.Span(error_message, style=STYLE_RED), dash.no_update, dash.no_update
        elif btn_id == ID_LOGOUT_BUTTON:
            # show the logout modal
            return dash.no_update, dash.no_update, True, dash.no_update
        elif btn_id == ID_LOGOUT_MODAL_CANCEL:
            # hide the logout modal
            return dash.no_update, dash.no_update, False, dash.no_update
        elif btn_id == ID_LOGOUT_MODAL_ACCEPT:
            # hide the logout modal and clear the login store
            return dash.no_update, dash.no_update, False, LOGIN_INFO_EMPTY
        else:  # error
            print(f"unknown button id: {btn_id}")  # TODO: better logging
            return [dash.no_update] * 4  # one for each Output

    @app.callback(Output(ID_LOGIN_BUTTON, "style"),
                  Output(ID_LOGGED_IN_AS, "children"),
                  Output(ID_LOGGED_IN_AS, "style"),
                  Output(ID_LOGOUT_BUTTON, "style"),
                  Input(ID_LOGIN_STORE, "data"),
                  State(ID_LOGIN_BUTTON, "style"),
                  State(ID_LOGGED_IN_AS, "style"),
                  State(ID_LOGOUT_BUTTON, "style"))
    def on_login_data_changed(login_store, login_style, logged_in_as_style, logout_style):
        # just in case no style is provided
        if login_style is None:
            login_style = dict()
        if logged_in_as_style is None:
            logged_in_as_style = dict()
        if logout_style is None:
            logout_style = dict()
        # are they logged in or not?
        if login_store[NET_ID] is None or login_store[PASSWORD] is None:
            # not logged in
            login_style["display"] = VISIBLE
            logged_in_as_style["display"] = HIDDEN
            logout_style["display"] = HIDDEN
            return login_style, None, logged_in_as_style, logout_style
        else:  # yes logged in
            login_style["display"] = HIDDEN
            logged_in_as_style["display"] = VISIBLE
            logout_style["display"] = VISIBLE
            return login_style, f"Logged in as '{login_store[NET_ID]}'", logged_in_as_style, logout_style

    # define callbacks for all of the tabs
    define_submit_callbacks(app)
    define_log_callbacks(app)
    define_config_callbacks(app)

website/tabs/submit.py

import time
from io import StringIO
from typing import Callable, Dict, Union

import dash
import dash_bootstrap_components as dbc
from dash import dcc, html
from dash.dependencies import Input, Output, State

from config.loaded_config import CONFIG
from driver.passoff_driver import PassoffDriver
from util.authenticator import authenticate
from website import ID_LOGIN_STORE, NET_ID, PASSWORD
from website.util import AUTH_FAILED_MESSAGE, save_to_submit, text_html_colorizer


# submit tab IDs
ID_SUBMIT_PROJECT_NUMBER_RADIO = "submit-project-number-radio"
ID_UPLOAD_BUTTON = "upload-button"
ID_UPLOAD_CONTENTS = "upload-contents"
ID_FILE_NAME_DISPLAY = "file-name-display"
ID_SUBMISSION_SUBMIT_BUTTON = "submission-submit-button"
ID_SUBMISSION_OUTPUT = "submission-output"
ID_DUMMY_DIV = "dummy-div"
# info modal
ID_SUBMISSION_INFO_MODAL = "submission-info-modal"
ID_SUBMISSION_INFO_MODAL_MESSAGE = "submission-info-modal-message"
ID_SUBMISSION_INFO_MODAL_ACCEPT = "submission-info-modal-accept"
# submission confirmation modal
ID_SUBMISSION_CONFIRMATION_MODAL = "submission-confirmation-modal"
ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL = "submission-confirmation-modal-cancel"
ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT = "submission-confirmation-modal-accept"
# store to trigger submission
ID_SUBMISSION_TRIGGER_STORE = "submission-trigger-store"


layout = html.Div(
    [
        html.H3("Upload New Submission"),
        html.P("Which project are you submitting?"),
        dcc.RadioItems(
            id=ID_SUBMIT_PROJECT_NUMBER_RADIO,
            options=[{
                "label": f" Project {proj_num}",
                "value": proj_num
            } for proj_num in range(1, CONFIG.n_projects + 1)]
        ),
        html.Br(),
        html.P("Upload your .zip file here:"),
        html.Div(
            [
                dcc.Upload(
                    html.Button("Select File", id=ID_UPLOAD_BUTTON),
                    id=ID_UPLOAD_CONTENTS,
                    multiple=False
                ),
                html.Pre("No File Selected", id=ID_FILE_NAME_DISPLAY, style={"marginLeft": "10px"})
            ],
            style={
                "display": "flex",
                "justifyContent": "flex-start",
                "alignItems": "center"
            }
        ),
        html.Br(),
        html.Button("Submit", id=ID_SUBMISSION_SUBMIT_BUTTON),
        html.Br(),
        html.Div(id=ID_SUBMISSION_OUTPUT, style={"marginTop": "20px"}),
        html.Br(),
        html.Div(id=ID_DUMMY_DIV),
        dbc.Modal(
            [
                dbc.ModalHeader("Try Again"),
                dbc.ModalBody(id=ID_SUBMISSION_INFO_MODAL_MESSAGE),
                dbc.ModalFooter([
                    html.Button("OK", id=ID_SUBMISSION_INFO_MODAL_ACCEPT)
                ])
            ],
            id=ID_SUBMISSION_INFO_MODAL,
            is_open=False
        ),
        dbc.Modal(
            [
                dbc.ModalHeader("Confirm Submission"),
                dbc.ModalBody("Are you sure you want to officially submit?"),
                dbc.ModalFooter([
                    html.Button("Cancel", id=ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL),
                    html.Button("Submit", id=ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT)
                ])
            ],
            id=ID_SUBMISSION_CONFIRMATION_MODAL,
            is_open=False
        ),
        dcc.Store(id=ID_SUBMISSION_TRIGGER_STORE, storage_type="memory", data=False)  # data value just flips to trigger the long callback
    ],
    style={
        "margin": "10px",
        "padding": "10px",
        "borderStyle": "double"
    }
)


def on_submit_button_clicked(
        proj_number: Union[int, None],
        file_name: Union[str, None],
        file_contents: Union[str, None],
        login_store: Union[Dict[str, str], None]) -> Union[str, None]:
    # validate
    if login_store is None or NET_ID not in login_store or PASSWORD not in login_store:
        return "There was a problem with the login store!"
    net_id = login_store[NET_ID]
    password = login_store[PASSWORD]
    if net_id is None or net_id == "" or password is None or password == "":
        return "You must log in before submitting."
    if proj_number is None:
        return "The project number must be selected."
    if not (1 <= proj_number <= CONFIG.n_projects):
        return "Invalid project selected."
    if file_name is None or file_name == "" or file_contents is None or file_contents == "":
        return "A zip file must be uploaded to submit."
    if not file_name.endswith(".zip"):
        return "The uploaded file must be a .zip file."
    # all good, it seems; return no error message
    return None


def run_submission(proj_number: int, file_contents: str, login_store: Dict[str, str]):  # -> html element(s)
    # authenticate
    net_id = login_store[NET_ID]
    password = login_store[PASSWORD]
    auth_success = authenticate(net_id, password)
    if not auth_success:
        return AUTH_FAILED_MESSAGE
    # write their zip file to the submit directory
    save_to_submit(proj_number, net_id, file_contents)
    # actually submit
    this_stdout = StringIO()  # TODO: use a long callback "progress" feature to write continuous output?
    this_stderr = StringIO()
    driver = PassoffDriver(stdout=this_stdout, stderr=this_stderr)
    submission_result = driver.run(net_id, proj_number, use_user_input=False)
    # TODO: change returned result^ from submission_result to diff/error info?
    # show results to the user
    output = list()
    output.append(html.P(f"submission for '{net_id}', project {proj_number}"))
    stdout_val = this_stdout.getvalue()
    output.append(html.P("stdout:"))
    output.append(html.Br())
    output.append(html.Pre(text_html_colorizer(stdout_val)))
    output.append(html.Br())
    stderr_val = this_stderr.getvalue()
    if stderr_val != "":
        output.append(html.P("stderr:"))
        output.append(html.Br())
        output.append(html.Pre(text_html_colorizer(stderr_val)))
    return output


def define_submit_callbacks(app: dash.Dash):
    @app.callback(Output(ID_FILE_NAME_DISPLAY, "children"),
                  Input(ID_UPLOAD_CONTENTS, "filename"),
                  prevent_initial_call=True)
    def on_select_file(filename: str):
        if filename is None or filename == "":
            return "No File Selected"
        return filename

    @app.callback(Output(ID_SUBMISSION_CONFIRMATION_MODAL, "is_open"),
                  Output(ID_SUBMISSION_INFO_MODAL, "is_open"),
                  Output(ID_SUBMISSION_INFO_MODAL_MESSAGE, "children"),
                  Output(ID_SUBMISSION_TRIGGER_STORE, "data"),
                  Input(ID_SUBMISSION_SUBMIT_BUTTON, "n_clicks"),
                  Input(ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL, "n_clicks"),
                  Input(ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT, "n_clicks"),
                  Input(ID_SUBMISSION_INFO_MODAL_ACCEPT, "n_clicks"),
                  State(ID_SUBMIT_PROJECT_NUMBER_RADIO, "value"),
                  State(ID_UPLOAD_CONTENTS, "filename"),
                  State(ID_UPLOAD_CONTENTS, "contents"),
                  State(ID_LOGIN_STORE, "data"),
                  State(ID_SUBMISSION_TRIGGER_STORE, "data"),
                  prevent_initial_call=True)
    def on_submission_submit_clicked(
            n_submit_clicks: int,
            n_confirmation_cancel_clicks: int,
            n_confirmation_accept_clicks: int,
            n_info_accept_clicks: int,
            proj_number: int,
            file_name: str,
            file_contents: str,
            login_store: Dict[str, str],
            submission_trigger_store: bool):
        ctx = dash.callback_context
        trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
        print("trigger:", trigger_id)
        if trigger_id == ID_SUBMISSION_SUBMIT_BUTTON:
            # validate
            error_message = on_submit_button_clicked(proj_number, file_name, file_contents, login_store)
            if error_message is None:  # good to go
                # show the confirmation modal
                return True, dash.no_update, dash.no_update, dash.no_update
            else:
                # show the error message in the info modal
                return dash.no_update, True, error_message, dash.no_update
        elif trigger_id == ID_SUBMISSION_CONFIRMATION_MODAL_CANCEL:
            # hide the confirmation modal
            return False, dash.no_update, dash.no_update, dash.no_update
        elif trigger_id == ID_SUBMISSION_CONFIRMATION_MODAL_ACCEPT:
            # hide the confirmation modal and trigger on_submit_confirmed
            return False, dash.no_update, dash.no_update, not submission_trigger_store  # just flip the value, whatever it is
        elif trigger_id == ID_SUBMISSION_INFO_MODAL_ACCEPT:
            # hide the info modal
            return dash.no_update, False, dash.no_update, dash.no_update
        else:  # error
            print(f"unknown button id: {trigger_id}")  # TODO: better logging
            return [dash.no_update] * 4  # one for each Output

    @app.long_callback(
        progress=[Output(ID_SUBMISSION_OUTPUT, "children")],
        progress_default=[dash.no_update],  # I'll set stuff manually, thank you very much
        output=Output(ID_DUMMY_DIV, "children"),
        inputs=[
            Input(ID_SUBMISSION_TRIGGER_STORE, "data"),
            State(ID_SUBMIT_PROJECT_NUMBER_RADIO, "value"),
            State(ID_UPLOAD_CONTENTS, "filename"),
            State(ID_UPLOAD_CONTENTS, "contents"),
            State(ID_LOGIN_STORE, "data")
        ],
        running=[(Output(ID_SUBMISSION_SUBMIT_BUTTON, "disabled"), True, False)],  # disable the submit button while running
        prevent_initial_call=True)
    def on_submit_confirmed(
            set_progress: Callable,
            submission_trigger_store: bool,
            proj_number: int,
            file_name: str,
            file_contents,
            login_store):
        # TODO
        print("long callback start")
        set_progress([None])
        # actually run the submission
        # submission_result = run_submission(proj_number, file_contents, login_store)
        output_children = list()
        for i in range(5):
            output_children.append(html.P(f"i = {i}"))
            set_progress([output_children])
            time.sleep(1)
        output_children.append(html.P("done submitting"))
        set_progress([output_children])
        timestamp = time.time()
        print(timestamp)
        # time.sleep(1)  # THIS FIXES IT - but that's dumb.
        return [f"Current timestamp: {timestamp}"]

Outputs

stdout and stderr

/home/echols14/miniconda3/envs/passoff/lib/python3.9/site-packages/dash_bootstrap_components/_table.py:5: UserWarning: 
The dash_html_components package is deprecated. Please replace
`import dash_html_components as html` with `from dash import html`
  import dash_html_components as html
Dash is running on http://0.0.0.0:8050/

 * Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
/home/echols14/miniconda3/envs/passoff/lib/python3.9/site-packages/dash_bootstrap_components/_table.py:5: UserWarning: 
The dash_html_components package is deprecated. Please replace
`import dash_html_components as html` with `from dash import html`
  import dash_html_components as html
trigger: submission-submit-button
trigger: submission-confirmation-modal-accept
long callback start
1632470283.8649454

Client screenshot