Adding callbacks within callbacks

I’m trying to add a button that, when clicked, does the following:

  • Make some UI updates to show the button has been clicked
  • Asynchronously execute a python function that takes several minutes to run and returns some data
  • When complete, stream the data into a ColumnDataSource

Running 3.3.0, and my code is based on the example in the docs:

    def on_button_click():
        run_current_button.disabled=True

        if(look_for_run()):
            info_text.text = 'Run exists'
            run_current_button.disabled = False
            return

        info_text.text = 'Run submitted'
        run_current_button.disabled = False #Allows user to queue more runs while waiting

        curdoc().add_next_tick_callback(run_current)
    
    @without_document_lock
    async def run_current():
        # Call run and await results
        new_run_data = await submitRun(model_location, run_name, inputs)

        curdoc().add_next_tick_callback(partial(after_run, data=new_run_data))

    async def after_run(data):
        table_data_source.stream(data)
        info_text.text = 'Run complete, see table'

....

    button = Button(label='Run current values')
    button.on_click(on_button_click)

With this code, submitRun successfully executes without locking the document, however:

  • after_run is never called and table_data_source does not get updated
  • The UI updates in on_button_click do not appear until after submitRun finishes

The main motivating use-case behind Bokeh was just simple one-off callbacks for a retained-mode document, ala Shiny. So while this kind of usage is possible in principle, in practice it definitely pushes the boundaries of what is typical for Bokeh, and what is regularly exercised and tested. I could try to make a very quick investigation to see if any specific observations or suggestions come to mind. But I would absolutely need something I could just copy and paste and run, i.e. a complete Minimal Reproducible Example

FWIW I was able to find a workaround by adding both callbacks to the original event. The drawback is I can’t pass data from the first callback to the second, requiring a global variable:

run_data = []
idx = 0

def on_button_click():
    run_current_button.disabled=True

    if(look_for_run()):
        info_text.text = 'Run exists'
        run_current_button.disabled = False
        return

    info_text.text = 'Run submitted'
    run_current_button.disabled = False #Allows user to queue more runs while waiting

    idx = idx + 1
    curdoc().add_next_tick_callback(partial(run_current, index=idx))
    curdoc().add_next_tick_callback(partial(after_run, index=idx))
    
@without_document_lock
async def run_current(index):
    # Call run and await results
    run_data[index] = await submitRun(model_location, run_name, inputs)

async def after_run(index):
    data = run_data[index]
    table_data_source.stream(data)
    info_text.text = 'Run complete, see table'

....

button = Button(label='Run current values')
button.on_click(on_button_click)

An alternative would be to use callbacks that are methods on the same instance of some object.