diff --git a/tests/tools/test_cronjob_run_immediate.py b/tests/tools/test_cronjob_run_immediate.py index 6588222a4bdf..e839f86cadc9 100644 --- a/tests/tools/test_cronjob_run_immediate.py +++ b/tests/tools/test_cronjob_run_immediate.py @@ -110,6 +110,22 @@ def test_execute_job_now_bails_without_claim(self): assert res["success"] is False m_run.assert_not_called() + def test_execute_job_now_handles_oneshot_removal(self): + """A one-shot job removed by mark_job_run should report success (#71760). + + For finite one-shot jobs (repeat.times=1), mark_job_run removes the + job from the store when completed >= times. get_job returns None in + that case, which previously caused execution_success=False even though + the job ran to completion. + """ + with patch("tools.cronjob_tools.claim_job_for_fire", return_value=True), \ + patch("cron.scheduler.run_one_job", return_value=True), \ + patch("tools.cronjob_tools.get_job", return_value=None): + res = _execute_job_now(dict(_JOB)) + assert res["claimed"] is True + assert res["success"] is True + assert res["error"] is None + def test_execute_job_now_marks_failure_on_exception(self): """An exception during fire is captured, marked failed, not propagated.""" with patch("tools.cronjob_tools.claim_job_for_fire", return_value=True), \ diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index a623d38c6f56..737ffaa1be94 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -639,7 +639,18 @@ def _execute_job_now(job: Dict[str, Any]) -> Dict[str, Any]: # run_one_job records last_run_at/last_status via mark_job_run (which # also clears the fire claim) and returns True iff it processed the job. processed = run_one_job(job) - refreshed = get_job(job_id) or {} + refreshed = get_job(job_id) + # For finite one-shot jobs, mark_job_run removes the job from the + # store when completed >= times. get_job returns None in that case, + # so we cannot read last_status — but processed=True means the job + # ran end-to-end successfully. Treat removal as implicit success + # rather than reporting a false "failed" to the operator (#71760). + if refreshed is None: + return { + "claimed": True, + "success": bool(processed), + "error": None, + } ok = refreshed.get("last_status") == "ok" return { "claimed": True,