From 0a6297f70984c632623cadd0b409fea1e7840f5f Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Fri, 24 Jul 2026 18:48:40 +0200 Subject: [PATCH 1/5] [IMP] queue_job: Implement on fail hook Allows to execute a model function when the job fails and will not be retried. --- queue_job/controllers/main.py | 1 + queue_job/delay.py | 7 +++++++ queue_job/job.py | 15 +++++++++++++++ queue_job/models/base.py | 4 ++++ queue_job/models/queue_job.py | 4 ++++ queue_job/tests/test_run_rob_controller.py | 18 ++++++++++++++++++ 6 files changed, 49 insertions(+) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index 46da185d4e..cbabc54ca7 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -186,6 +186,7 @@ def retry_postpone(job, message, seconds=None): vals = cls._get_failure_values(job, traceback_txt, orig_exception) job.set_failed(**vals) job.store() + job.on_fail_hook(vals) buff.close() raise diff --git a/queue_job/delay.py b/queue_job/delay.py index 60e98dc9d6..afb3adcb85 100644 --- a/queue_job/delay.py +++ b/queue_job/delay.py @@ -438,6 +438,7 @@ class Delayable: "description", "channel", "identity_key", + "on_fail_method", ) __slots__ = _properties + ( "recordset", @@ -457,6 +458,7 @@ def __init__( description=None, channel=None, identity_key=None, + on_fail_method=None, ): self._graph = DelayableGraph() self._graph.add_vertex(self) @@ -469,6 +471,7 @@ def __init__( self.description = description self.channel = channel self.identity_key = identity_key + self.on_fail_method = on_fail_method self._job_method = None self._job_args = () @@ -547,6 +550,7 @@ def split(self, size, chain=False): description=self.description, channel=self.channel, identity_key=self.identity_key, + on_fail_method=self.on_fail_method, ) # Update the __self__ delayable._job_method = getattr(recordset, self._job_method.__name__) @@ -583,6 +587,7 @@ def _build_job(self): description=self.description, channel=self.channel, identity_key=self.identity_key, + on_fail_method=self.on_fail_method, ) return self._generated_job @@ -633,6 +638,7 @@ def __init__( description=None, channel=None, identity_key=None, + on_fail_method=None, ): self.delayable = Delayable( recordset, @@ -642,6 +648,7 @@ def __init__( description=description, channel=channel, identity_key=identity_key, + on_fail_method=on_fail_method, ) @property diff --git a/queue_job/job.py b/queue_job/job.py index 86314499bd..fa851a7099 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -200,6 +200,9 @@ class Job: be added to a channel if the existing job with the same key is not yet started or executed. + .. attribute::on_fail_method + + A function to be called if the job is failed and will not be retried. """ @classmethod @@ -298,6 +301,7 @@ def _load_from_db_record(cls, job_db_record): description=stored.name, channel=stored.channel, identity_key=stored.identity_key, + on_fail_method=stored.on_fail_method, ) if stored.date_created: @@ -365,6 +369,7 @@ def __init__( description=None, channel=None, identity_key=None, + on_fail_method=None, ): """Create a Job @@ -407,6 +412,11 @@ def __init__( self.method_name = func.__name__ self.recordset = recordset + if on_fail_method: + if not _is_model_method(on_fail_method): + raise TypeError("Job accepts only methods of Models") + self.on_fail_method_name = on_fail_method.__name__ + self.job_config = ( self.env["queue.job.function"].sudo().job_config(self.job_function_name) ) @@ -830,6 +840,11 @@ def set_failed(self, **kw): if v is not None: setattr(self, k, v) + def on_fail_hook(self, fail_vals): + on_fail_func = getattr(self.recordset, self.on_fail_method_name, None) + if on_fail_func: + on_fail_func(**fail_vals) + def __repr__(self): return f"" diff --git a/queue_job/models/base.py b/queue_job/models/base.py index 2c71cafc17..5a256c1704 100644 --- a/queue_job/models/base.py +++ b/queue_job/models/base.py @@ -26,6 +26,7 @@ def with_delay( description=None, channel=None, identity_key=None, + on_fail_method=None, ): """Return a ``DelayableRecordset`` @@ -59,6 +60,7 @@ def with_delay( description=description, channel=channel, identity_key=identity_key, + on_fail_method=on_fail_method, ) def delayable( @@ -69,6 +71,7 @@ def delayable( description=None, channel=None, identity_key=None, + on_fail_method=None, ): """Return a ``Delayable`` @@ -140,6 +143,7 @@ def delayable( description=description, channel=channel, identity_key=identity_key, + on_fail_method=on_fail_method, ) def _patch_job_auto_delay(self, method_name, context_key=None): diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 88b6b4e773..be03118e26 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -77,6 +77,7 @@ class QueueJob(models.Model): model_name = fields.Char(string="Model", readonly=True) method_name = fields.Char(readonly=True) + on_fail_method_name = fields.Char(readonly=True) records = JobSerialized( string="Record(s)", readonly=True, @@ -490,3 +491,6 @@ def _test_job( time.sleep(job_duration) if commit_within_job: self.env.cr.commit() # pylint: disable=invalid-commit + + def _test_on_fail_hook(self, **kw): + pass diff --git a/queue_job/tests/test_run_rob_controller.py b/queue_job/tests/test_run_rob_controller.py index 1a15f4363a..60939f35b1 100644 --- a/queue_job/tests/test_run_rob_controller.py +++ b/queue_job/tests/test_run_rob_controller.py @@ -1,8 +1,10 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from unittest.mock import patch from odoo.tests.common import TransactionCase from ..controllers.main import RunJobController +from ..exception import JobError from ..job import Job @@ -21,3 +23,19 @@ def test_runjob_success(self): RunJobController._runjob(self.env, job) self.assertEqual(job.state, "done") self.assertEqual(job.db_record().state, "done") + + def test_runjob_on_fail_hook(self): + job = ( + self.env["queue.job"] + .with_delay(on_fail_method=self.env["queue.job"]._test_on_fail_hook) + ._test_job(failure_rate=1) + ) + with ( + self.assertRaises(JobError), + patch( + "odoo.addons.queue_job.models.queue_job.QueueJob._test_on_fail_hook" + ) as mocked_hook, + ): + RunJobController._runjob(self.env, job) + self.assertEqual(job.state, "failed") + self.assertEqual(mocked_hook.call_count, 1) From d2e54c2564aaa5c72847843b73745eb7b275c12d Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Fri, 24 Jul 2026 20:37:36 +0200 Subject: [PATCH 2/5] fixup! [IMP] queue_job: Implement on fail hook --- queue_job/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/queue_job/job.py b/queue_job/job.py index fa851a7099..98c442d501 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -301,7 +301,7 @@ def _load_from_db_record(cls, job_db_record): description=stored.name, channel=stored.channel, identity_key=stored.identity_key, - on_fail_method=stored.on_fail_method, + on_fail_method=stored.on_fail_method_name, ) if stored.date_created: From ced38402ee2cd9c4a18b973294ab76af31e4f24c Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 27 Jul 2026 15:42:06 +0200 Subject: [PATCH 3/5] fixup! fixup! [IMP] queue_job: Implement on fail hook --- test_queue_job/tests/test_delay_mocks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test_queue_job/tests/test_delay_mocks.py b/test_queue_job/tests/test_delay_mocks.py index 16b87ebb80..21d68d6fdf 100644 --- a/test_queue_job/tests/test_delay_mocks.py +++ b/test_queue_job/tests/test_delay_mocks.py @@ -308,6 +308,7 @@ def test_mock_with_delay(self): "eta": 15, "identity_key": identity_exact, "max_retries": 1, + 'on_fail_method': None, "priority": 15, }, ) From ea4db16ce9be663bfd62403221d2c224fde1d8c1 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Tue, 28 Jul 2026 23:26:21 +0200 Subject: [PATCH 4/5] fixup! fixup! fixup! [IMP] queue_job: Implement on fail hook --- queue_job/tests/test_run_rob_controller.py | 8 ++++++++ test_queue_job/tests/test_autovacuum.py | 1 - test_queue_job/tests/test_delay_mocks.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/queue_job/tests/test_run_rob_controller.py b/queue_job/tests/test_run_rob_controller.py index 60939f35b1..45f00b521b 100644 --- a/queue_job/tests/test_run_rob_controller.py +++ b/queue_job/tests/test_run_rob_controller.py @@ -9,6 +9,12 @@ class TestRunJobController(TransactionCase): + def setUp(cls): + super().setUp() + def _clean_queue_job(): + cls.env["queue.job"].search([]).unlink() + cls.addCleanup(_clean_queue_job) + def test_get_failure_values(self): method = self.env["res.users"].mapped job = Job(method) @@ -35,7 +41,9 @@ def test_runjob_on_fail_hook(self): patch( "odoo.addons.queue_job.models.queue_job.QueueJob._test_on_fail_hook" ) as mocked_hook, + patch("odoo.addons.queue_job.job.Job.in_temporary_env") as mocked_temp_env, ): + mocked_temp_env.return_value.__enter__.return_value = self.env RunJobController._runjob(self.env, job) self.assertEqual(job.state, "failed") self.assertEqual(mocked_hook.call_count, 1) diff --git a/test_queue_job/tests/test_autovacuum.py b/test_queue_job/tests/test_autovacuum.py index 32378a14ac..4107c33885 100644 --- a/test_queue_job/tests/test_autovacuum.py +++ b/test_queue_job/tests/test_autovacuum.py @@ -51,7 +51,6 @@ def test_autovacuum_multi_channel(self): job_60days.write( {"channel": channel_60days.complete_name, "date_done": date_done} ) - self.assertEqual( len(self.env["queue.job"].search([("channel", "!=", False)])), 2 ) diff --git a/test_queue_job/tests/test_delay_mocks.py b/test_queue_job/tests/test_delay_mocks.py index 21d68d6fdf..5ba75d79ed 100644 --- a/test_queue_job/tests/test_delay_mocks.py +++ b/test_queue_job/tests/test_delay_mocks.py @@ -308,7 +308,7 @@ def test_mock_with_delay(self): "eta": 15, "identity_key": identity_exact, "max_retries": 1, - 'on_fail_method': None, + "on_fail_method": None, "priority": 15, }, ) From 62b1b1560dd35bb94a0f5f9cfde6b0b1b6d866c5 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 29 Jul 2026 00:04:33 +0200 Subject: [PATCH 5/5] fixup! fixup! fixup! fixup! [IMP] queue_job: Implement on fail hook --- queue_job/tests/test_run_rob_controller.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/queue_job/tests/test_run_rob_controller.py b/queue_job/tests/test_run_rob_controller.py index 45f00b521b..49417364ef 100644 --- a/queue_job/tests/test_run_rob_controller.py +++ b/queue_job/tests/test_run_rob_controller.py @@ -11,8 +11,10 @@ class TestRunJobController(TransactionCase): def setUp(cls): super().setUp() + def _clean_queue_job(): cls.env["queue.job"].search([]).unlink() + cls.addCleanup(_clean_queue_job) def test_get_failure_values(self):