From ea7f534be73bb5d62c1656c1d0fb748f8783f095 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Sat, 26 Sep 2026 15:36:41 -0400 Subject: [PATCH 1/3] Fix background assembly waiting after task result expiration --- .../data/questions/al_document.yml | 36 +++++ .../test_aldocument_background_assembly.yml | 17 ++- .../AssemblyLine/test_background_assembly.py | 132 ++++++++++++++++++ 3 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 docassemble/AssemblyLine/test_background_assembly.py diff --git a/docassemble/AssemblyLine/data/questions/al_document.yml b/docassemble/AssemblyLine/data/questions/al_document.yml index 80902b7a..48575f8c 100644 --- a/docassemble/AssemblyLine/data/questions/al_document.yml +++ b/docassemble/AssemblyLine/data/questions/al_document.yml @@ -139,8 +139,30 @@ code: | --- ############## Background document assembly ################# --- +# Use these saved completion variables in interview order blocks. Celery's +# task.ready() becomes False again when its result expires (normally one day). generic object: ALDocumentBundle code: | + x.generate_downloads_task + if not defined(x.attr_name("_downloadable_files")): + al_download_waiting_screen + x.downloads_ready = True +--- +generic object: ALDocumentBundle +code: | + x.generate_downloads_with_docx_task + if not defined(x.attr_name("_downloadable_files")): + al_download_waiting_screen + x.downloads_with_docx_ready = True +--- +generic object: ALDocumentBundle +code: | + undefine( + x.attr_name("_downloadable_files"), + x.attr_name("downloads_ready"), + x.attr_name("downloads_with_docx_ready"), + x.attr_name("generate_downloads_with_docx_task"), + ) x.generate_downloads_task = background_action(x.attr_name("create_downloads")) --- generic object: ALDocumentBundle @@ -155,6 +177,12 @@ code: | --- generic object: ALDocumentBundle code: | + undefine( + x.attr_name("_downloadable_files"), + x.attr_name("downloads_ready"), + x.attr_name("downloads_with_docx_ready"), + x.attr_name("generate_downloads_task"), + ) x.generate_downloads_with_docx_task = background_action( x.attr_name("create_downloads_with_docx") ) @@ -192,6 +220,14 @@ reload: True --- generic object: ALDocumentBundle code: | + x.generate_preview_task + if not defined(x.attr_name("_preview_file")): + al_preview_waiting_screen + x.preview_ready = True +--- +generic object: ALDocumentBundle +code: | + undefine(x.attr_name("_preview_file"), x.attr_name("preview_ready")) x.generate_preview_task = background_action(x.attr_name("generate_preview_event")) --- generic object: ALDocumentBundle diff --git a/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml b/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml index 4401fe8f..daefdd53 100644 --- a/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml +++ b/docassemble/AssemblyLine/data/questions/test_aldocument_background_assembly.yml @@ -8,12 +8,10 @@ metadata: mandatory: True code: | intro_screen - if not al_user_bundle.generate_preview_task.ready(): - al_preview_waiting_screen + al_user_bundle.preview_ready preview_screen - # if not al_user_bundle.generate_downloads_task.ready(): # Without DOCX - if not al_user_bundle.generate_downloads_with_docx_task.ready(): # With DOCX - al_download_waiting_screen + # Use al_user_bundle.downloads_ready for PDF-only downloads. + al_user_bundle.downloads_with_docx_ready download_screen --- id: intro-screen @@ -40,6 +38,15 @@ subquestion: | Your documents are ready for download. ${ al_user_bundle.download_list_html(use_previously_cached_files=True, include_full_pdf=True) } + + ${ action_button_html(url_action('regenerate_downloads'), label='Regenerate documents') } +--- +# Use this action after collecting any changed attachment inputs. Starting the +# task clears its old saved files and completion gate; the order block waits for +# the replacement files. Do not reconsider the task on every mandatory pass. +event: regenerate_downloads +code: | + reconsider("al_user_bundle.generate_downloads_with_docx_task") --- objects: - al_user_bundle: ALDocumentBundle.using( diff --git a/docassemble/AssemblyLine/test_background_assembly.py b/docassemble/AssemblyLine/test_background_assembly.py new file mode 100644 index 00000000..edefb2bd --- /dev/null +++ b/docassemble/AssemblyLine/test_background_assembly.py @@ -0,0 +1,132 @@ +# do not pre-load + +"""Exercise the standard YAML gates with an expired Celery result.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import yaml + +BLOCKS = list( + yaml.safe_load_all( + (Path(__file__).parent / "data/questions/al_document.yml").read_text() + ) +) +MODES = [ + ( + "generate_preview_task", + "preview_ready", + "_preview_file", + "al_preview_waiting_screen", + ), + ( + "generate_downloads_task", + "downloads_ready", + "_downloadable_files", + "al_download_waiting_screen", + ), + ( + "generate_downloads_with_docx_task", + "downloads_with_docx_ready", + "_downloadable_files", + "al_download_waiting_screen", + ), +] + + +def code_defining(attribute): + return next( + block["code"] + for block in BLOCKS + if block + and any( + line.startswith(f"x.{attribute} =") + for line in block.get("code", "").splitlines() + ) + ) + + +def context(bundle): + def undefine(*names): + for name in names: + bundle.__dict__.pop(name.split(".", 1)[1], None) + + return { + "x": bundle, + "defined": lambda name: name.split(".", 1)[1] in vars(bundle), + "undefine": undefine, + "background_action": Mock(return_value=Mock()), + } + + +@pytest.mark.parametrize("task,gate,cache,waiting", MODES) +@pytest.mark.parametrize("saved_result", [None, (), "saved file"]) +def test_saved_callback_result_survives_expired_task( + task, gate, cache, waiting, saved_result +): + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + expired = Mock() + expired.ready.return_value = False + setattr(bundle, task, expired) + setattr(bundle, cache, saved_result) + env = context(bundle) + exec(code_defining(gate), env) + assert getattr(bundle, gate) is True + expired.ready.assert_not_called() + env["background_action"].assert_not_called() + + +@pytest.mark.parametrize("task,gate,cache,waiting", MODES) +def test_missing_callback_result_waits_without_marking_complete( + task, gate, cache, waiting +): + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + setattr(bundle, task, Mock()) + with pytest.raises(NameError, match=waiting): + exec(code_defining(gate), context(bundle)) + assert gate not in vars(bundle) + + +@pytest.mark.parametrize("task,gate,cache,waiting", MODES) +def test_restarting_task_discards_old_files_and_completion(task, gate, cache, waiting): + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + setattr(bundle, gate, True) + setattr(bundle, cache, "outdated file") + env = context(bundle) + exec(code_defining(task), env) + assert gate not in vars(bundle) + assert cache not in vars(bundle) + env["background_action"].assert_called_once() + with pytest.raises(NameError, match=waiting): + exec(code_defining(gate), env) + setattr(bundle, cache, "replacement file") + exec(code_defining(gate), env) + assert getattr(bundle, gate) is True + + +@pytest.mark.parametrize( + "task,other_task", + [ + ("generate_downloads_task", "generate_downloads_with_docx_task"), + ("generate_downloads_with_docx_task", "generate_downloads_task"), + ], +) +def test_switching_download_mode_invalidates_shared_results(task, other_task): + bundle = SimpleNamespace( + attr_name=lambda name: "bundle." + name, + downloads_ready=True, + downloads_with_docx_ready=True, + _downloadable_files="files from the other mode", + ) + setattr(bundle, other_task, Mock()) + exec(code_defining(task), context(bundle)) + for stale in ( + other_task, + "downloads_ready", + "downloads_with_docx_ready", + "_downloadable_files", + ): + assert stale not in vars(bundle) + assert task in vars(bundle) From f73b2949e7795f16c256ca1fbddf323e5ab4475a Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 6 Oct 2026 13:47:18 -0400 Subject: [PATCH 2/3] Handle background assembly failures with durable retry screens --- .../data/questions/al_document.yml | 69 +++++++- .../AssemblyLine/test_background_assembly.py | 153 +++++++++++++++++- 2 files changed, 217 insertions(+), 5 deletions(-) diff --git a/docassemble/AssemblyLine/data/questions/al_document.yml b/docassemble/AssemblyLine/data/questions/al_document.yml index 48575f8c..517e1e7f 100644 --- a/docassemble/AssemblyLine/data/questions/al_document.yml +++ b/docassemble/AssemblyLine/data/questions/al_document.yml @@ -141,10 +141,17 @@ code: | --- # Use these saved completion variables in interview order blocks. Celery's # task.ready() becomes False again when its result expires (normally one day). +# Error callbacks also save durable state. While waiting, check completed tasks +# for failures from before these callbacks existed. failed() can block, so it +# must only be called after ready(). Neither is needed when a saved result exists. generic object: ALDocumentBundle code: | x.generate_downloads_task if not defined(x.attr_name("_downloadable_files")): + if defined(x.attr_name("_download_error")) or ( + x.generate_downloads_task.ready() and x.generate_downloads_task.failed() + ): + x.downloads_error_screen al_download_waiting_screen x.downloads_ready = True --- @@ -152,6 +159,10 @@ generic object: ALDocumentBundle code: | x.generate_downloads_with_docx_task if not defined(x.attr_name("_downloadable_files")): + if defined(x.attr_name("_download_error")) or ( + x.generate_downloads_with_docx_task.ready() and x.generate_downloads_with_docx_task.failed() + ): + x.downloads_error_screen al_download_waiting_screen x.downloads_with_docx_ready = True --- @@ -159,6 +170,7 @@ generic object: ALDocumentBundle code: | undefine( x.attr_name("_downloadable_files"), + x.attr_name("_download_error"), x.attr_name("downloads_ready"), x.attr_name("downloads_with_docx_ready"), x.attr_name("generate_downloads_with_docx_task"), @@ -168,6 +180,7 @@ code: | generic object: ALDocumentBundle event: x.create_downloads code: | + background_error_action(x.attr_name("save_download_error")) download_response = x.get_cacheable_documents( key="final", pdf=True, include_full_pdf=True ) @@ -179,6 +192,7 @@ generic object: ALDocumentBundle code: | undefine( x.attr_name("_downloadable_files"), + x.attr_name("_download_error"), x.attr_name("downloads_ready"), x.attr_name("downloads_with_docx_ready"), x.attr_name("generate_downloads_task"), @@ -190,6 +204,7 @@ code: | generic object: ALDocumentBundle event: x.create_downloads_with_docx code: | + background_error_action(x.attr_name("save_download_error")) download_response = x.get_cacheable_documents( key="final", pdf=True, docx=True, include_full_pdf=True ) @@ -204,6 +219,29 @@ code: | background_response() --- +generic object: ALDocumentBundle +event: x.save_download_error +code: | + x._download_error = True + background_response() +--- +generic object: ALDocumentBundle +event: x.downloads_error_screen +question: | + We could not make your documents +subquestion: | + Your answers have been saved. You can try again. + + ${ action_button_html(url_action(x.attr_name('retry_downloads')), label='Try again') } +--- +generic object: ALDocumentBundle +event: x.retry_downloads +code: | + if defined(x.attr_name("generate_downloads_with_docx_task")): + reconsider(x.attr_name("generate_downloads_with_docx_task")) + else: + reconsider(x.attr_name("generate_downloads_task")) +--- id: waiting screen question: | Please wait while we make your documents @@ -222,17 +260,26 @@ generic object: ALDocumentBundle code: | x.generate_preview_task if not defined(x.attr_name("_preview_file")): + if defined(x.attr_name("_preview_error")) or ( + x.generate_preview_task.ready() and x.generate_preview_task.failed() + ): + x.preview_error_screen al_preview_waiting_screen x.preview_ready = True --- generic object: ALDocumentBundle code: | - undefine(x.attr_name("_preview_file"), x.attr_name("preview_ready")) + undefine( + x.attr_name("_preview_file"), + x.attr_name("_preview_error"), + x.attr_name("preview_ready"), + ) x.generate_preview_task = background_action(x.attr_name("generate_preview_event")) --- generic object: ALDocumentBundle event: x.generate_preview_event code: | + background_error_action(x.attr_name("save_preview_error")) preview_response = x.as_pdf(key="preview") background_response_action( x.attr_name("save_preview"), preview_response=preview_response @@ -244,6 +291,26 @@ code: | x._preview_file = action_argument("preview_response") background_response() --- +generic object: ALDocumentBundle +event: x.save_preview_error +code: | + x._preview_error = True + background_response() +--- +generic object: ALDocumentBundle +event: x.preview_error_screen +question: | + We could not generate a preview of your document +subquestion: | + Your answers have been saved. You can try again. + + ${ action_button_html(url_action(x.attr_name('retry_preview')), label='Try again') } +--- +generic object: ALDocumentBundle +event: x.retry_preview +code: | + reconsider(x.attr_name("generate_preview_task")) +--- id: preview waiting screen question: | Please wait while we generate a preview of your document diff --git a/docassemble/AssemblyLine/test_background_assembly.py b/docassemble/AssemblyLine/test_background_assembly.py index edefb2bd..920ef6c7 100644 --- a/docassemble/AssemblyLine/test_background_assembly.py +++ b/docassemble/AssemblyLine/test_background_assembly.py @@ -1,6 +1,6 @@ # do not pre-load -"""Exercise the standard YAML gates with an expired Celery result.""" +"""Exercise durable background assembly completion, failure, and retry.""" from pathlib import Path from types import SimpleNamespace @@ -34,6 +34,32 @@ "al_download_waiting_screen", ), ] +ERROR_MODES = [ + ( + "generate_preview_task", + "_preview_error", + "preview_error_screen", + "retry_preview", + "generate_preview_event", + "save_preview_error", + ), + ( + "generate_downloads_task", + "_download_error", + "downloads_error_screen", + "retry_downloads", + "create_downloads", + "save_download_error", + ), + ( + "generate_downloads_with_docx_task", + "_download_error", + "downloads_error_screen", + "retry_downloads", + "create_downloads_with_docx", + "save_download_error", + ), +] def code_defining(attribute): @@ -57,10 +83,20 @@ def undefine(*names): "x": bundle, "defined": lambda name: name.split(".", 1)[1] in vars(bundle), "undefine": undefine, - "background_action": Mock(return_value=Mock()), + "background_action": Mock( + return_value=Mock( + ready=Mock(return_value=False), failed=Mock(return_value=False) + ) + ), } +def event_block(event): + return next( + block for block in BLOCKS if block and block.get("event") == "x." + event + ) + + @pytest.mark.parametrize("task,gate,cache,waiting", MODES) @pytest.mark.parametrize("saved_result", [None, (), "saved file"]) def test_saved_callback_result_survives_expired_task( @@ -75,18 +111,25 @@ def test_saved_callback_result_survives_expired_task( exec(code_defining(gate), env) assert getattr(bundle, gate) is True expired.ready.assert_not_called() + expired.failed.assert_not_called() env["background_action"].assert_not_called() @pytest.mark.parametrize("task,gate,cache,waiting", MODES) +@pytest.mark.parametrize("task_ready", [False, True]) def test_missing_callback_result_waits_without_marking_complete( - task, gate, cache, waiting + task, gate, cache, waiting, task_ready ): bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) - setattr(bundle, task, Mock()) + pending = Mock(ready=Mock(return_value=task_ready), failed=Mock(return_value=False)) + setattr(bundle, task, pending) with pytest.raises(NameError, match=waiting): exec(code_defining(gate), context(bundle)) assert gate not in vars(bundle) + if task_ready: + pending.failed.assert_called_once_with() + else: + pending.failed.assert_not_called() @pytest.mark.parametrize("task,gate,cache,waiting", MODES) @@ -130,3 +173,105 @@ def test_switching_download_mode_invalidates_shared_results(task, other_task): ): assert stale not in vars(bundle) assert task in vars(bundle) + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_failed_task_stops_waiting_without_marking_complete( + task, error, screen, retry, create, save +): + gate = next(mode[1] for mode in MODES if mode[0] == task) + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + failed = Mock(ready=Mock(return_value=True), failed=Mock(return_value=True)) + setattr(bundle, task, failed) + with pytest.raises(AttributeError, match=screen): + exec(code_defining(gate), context(bundle)) + assert gate not in vars(bundle) + failed.failed.assert_called_once_with() + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_saved_failure_survives_expiration_and_retry( + task, error, screen, retry, create, save +): + gate, cache, waiting = next(mode[1:] for mode in MODES if mode[0] == task) + bundle = SimpleNamespace(attr_name=lambda name: "bundle." + name) + expired = Mock(failed=Mock(return_value=False), ready=Mock(return_value=False)) + setattr(bundle, task, expired) + env = context(bundle) + env["background_response"] = Mock() + exec(event_block(save)["code"], env) + assert getattr(bundle, error) is True + env["background_response"].assert_called_once_with() + + with pytest.raises(AttributeError, match=screen): + exec(code_defining(gate), env) + assert gate not in vars(bundle) + expired.failed.assert_not_called() + expired.ready.assert_not_called() + env["background_action"].assert_not_called() + + def reconsider(name): + assert name == "bundle." + task + exec(code_defining(task), env) + + env["reconsider"] = Mock(side_effect=reconsider) + exec(event_block(retry)["code"], env) + env["reconsider"].assert_called_once_with("bundle." + task) + assert error not in vars(bundle) + env["background_action"].assert_called_once() + with pytest.raises(NameError, match=waiting): + exec(code_defining(gate), env) + setattr(bundle, cache, "replacement file") + exec(code_defining(gate), env) + assert getattr(bundle, gate) is True + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_error_callback_registered_before_assembly( + task, error, screen, retry, create, save +): + assemble = Mock(side_effect=RuntimeError("attachment failed")) + registered = Mock() + bundle = SimpleNamespace( + attr_name=lambda name: "bundle." + name, + get_cacheable_documents=assemble, + as_pdf=assemble, + ) + calls = Mock() + calls.attach_mock(registered, "register") + calls.attach_mock(assemble, "assemble") + with pytest.raises(RuntimeError, match="attachment failed"): + exec( + event_block(create)["code"], + {"x": bundle, "background_error_action": registered}, + ) + registered.assert_called_once_with("bundle." + save) + assert [call[0] for call in calls.mock_calls] == ["register", "assemble"] + + +@pytest.mark.parametrize("task,error,screen,retry,create,save", ERROR_MODES) +def test_regeneration_clears_failure_for_its_output_only( + task, error, screen, retry, create, save +): + bundle = SimpleNamespace( + attr_name=lambda name: "bundle." + name, + _download_error=True, + _preview_error=True, + ) + exec(code_defining(task), context(bundle)) + assert error not in vars(bundle) + other_error = "_download_error" if error == "_preview_error" else "_preview_error" + assert getattr(bundle, other_error) is True + + +@pytest.mark.parametrize( + "screen,retry", + [ + ("downloads_error_screen", "retry_downloads"), + ("preview_error_screen", "retry_preview"), + ], +) +def test_error_screen_offers_retry_without_reloading(screen, retry): + block = event_block(screen) + assert not block.get("reload") + assert "url_action(x.attr_name('" + retry + "'))" in block["subquestion"] From 0454060071436e9a3f4dd05b9922acdcfe1d0005 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 6 Oct 2026 14:32:10 -0400 Subject: [PATCH 3/3] Add required IDs to background assembly error screens --- docassemble/AssemblyLine/data/questions/al_document.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docassemble/AssemblyLine/data/questions/al_document.yml b/docassemble/AssemblyLine/data/questions/al_document.yml index 517e1e7f..809f16e9 100644 --- a/docassemble/AssemblyLine/data/questions/al_document.yml +++ b/docassemble/AssemblyLine/data/questions/al_document.yml @@ -227,6 +227,7 @@ code: | --- generic object: ALDocumentBundle event: x.downloads_error_screen +id: background downloads error screen question: | We could not make your documents subquestion: | @@ -299,6 +300,7 @@ code: | --- generic object: ALDocumentBundle event: x.preview_error_screen +id: background preview error screen question: | We could not generate a preview of your document subquestion: |