Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[16.0][IMP] queue_job: Add error handler when job fails #734

Open
wants to merge 2 commits into
base: 16.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions queue_job/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Features:
description, number of retries
* Related Actions: link an action on the job view, such as open the record
concerned by the job
* Error Handler: trigger a method when job fails, such as calling a webhook

**Table of contents**

Expand Down Expand Up @@ -429,6 +430,21 @@ Based on this configuration, we can tell that:
* retries 10 to 15 postponed 30 seconds later
* all subsequent retries postponed 5 minutes later

**Job function: Error Handler**

The *Error Handler* is a method executed whenever the job fails

It's configured similarly to Related Action

There is an OOTB handler: _call_webhook, which calls a webhook with configurable information.

Example of using _call_webhook to call a webhook to Slack:

.. code-block:: xml

<field name="error_handler" eval='{"func_name": "_call_webhook", "kwargs": {"webhook_url": "XXX", "only_if_max_retries_reached":True, "payload": {"text": "Hello World!"}}}' />


**Job Context**

The context of the recordset of the job, or any recordset passed in arguments of
Expand Down Expand Up @@ -687,6 +703,7 @@ Contributors
* Souheil Bejaoui <[email protected]>
* Eric Antones <[email protected]>
* Simone Orsi <[email protected]>
* Tris Doan <[email protected]>

Maintainers
~~~~~~~~~~~
Expand Down
9 changes: 8 additions & 1 deletion queue_job/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ def _try_perform_job(self, env, job):
env.cr.commit()
_logger.debug("%s started", job)

job.perform()
try:
job.perform()
except Exception as exc:
with registry(job.env.cr.dbname).cursor() as new_cr:
job.env = job.env(cr=new_cr)
job.error_handler(exc)
raise

# Triggers any stored computed fields before calling 'set_done'
# so that will be part of the 'exec_time'
env.flush_all()
Expand Down
18 changes: 18 additions & 0 deletions queue_job/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,24 @@
action_kwargs = self.job_config.related_action_kwargs
return action(**action_kwargs)

def error_handler(self, exc):
record = self.db_record()
funcname = self.job_config.error_handler_func_name
if not self.job_config.error_handler_enable or not funcname:
return None

Check warning on line 898 in queue_job/job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/job.py#L898

Added line #L898 was not covered by tests

if not isinstance(funcname, str):
raise ValueError(

Check warning on line 901 in queue_job/job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/job.py#L901

Added line #L901 was not covered by tests
"error_handler must be the name of the method on queue.job as string"
)
action = getattr(record, funcname)
_logger.info("Job %s fails due to %s, execute %s", self.uuid, exc, action)
action_kwargs = {**self.job_config.error_handler_kwargs, "job": self}
try:
return action(**action_kwargs)
except Exception as exc:
_logger.warning("Error handler failed: %s", exc)

Check warning on line 910 in queue_job/job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/job.py#L909-L910

Added lines #L909 - L910 were not covered by tests


def _is_model_method(func):
return inspect.ismethod(func) and isinstance(
Expand Down
31 changes: 31 additions & 0 deletions queue_job/models/queue_job.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)

import json
import logging
import random
from datetime import datetime, timedelta

import requests

from odoo import _, api, exceptions, fields, models
from odoo.osv import expression
from odoo.tools import config, html_escape
Expand Down Expand Up @@ -506,3 +509,31 @@
_logger.info("Running test job.")
if random.random() <= failure_rate:
raise JobError("Job failed")

def _call_webhook(self, **kwargs):
only_if_max_retries_reached = kwargs.get("only_if_max_retries_reached")
job = kwargs.get("job")

Check warning on line 515 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L514-L515

Added lines #L514 - L515 were not covered by tests
if only_if_max_retries_reached and job and job.retry < job.max_retries:
return

Check warning on line 517 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L517

Added line #L517 was not covered by tests

webhook_url = kwargs.get("webhook_url")

Check warning on line 519 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L519

Added line #L519 was not covered by tests
if not webhook_url:
return
payload = kwargs.get("payload")
json_values = json.dumps(payload, sort_keys=True, default=str)
headers = kwargs.get("headers", {"Content-Type": "application/json"})

Check warning on line 524 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L521-L524

Added lines #L521 - L524 were not covered by tests
# inspired by https://github.com/odoo/odoo/blob/18.0/odoo/addons/base
# /models/ir_actions.py#L867
try:
response = requests.post(

Check warning on line 528 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L527-L528

Added lines #L527 - L528 were not covered by tests
url=webhook_url, data=json_values, headers=headers, timeout=1
)
response.raise_for_status()
except requests.exceptions.ReadTimeout:
_logger.warning(

Check warning on line 533 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L531-L533

Added lines #L531 - L533 were not covered by tests
"Webhook call timed out after 1s - it may or may not have failed. "
"If this happens often, it may be a sign that the system you're "
"trying to reach is slow or non-functional."
)
except requests.exceptions.RequestException as exc:
_logger.warning("Webhook call failed: %s", exc)

Check warning on line 539 in queue_job/models/queue_job.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job.py#L538-L539

Added lines #L538 - L539 were not covered by tests
44 changes: 44 additions & 0 deletions queue_job/models/queue_job_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
"related_action_enable "
"related_action_func_name "
"related_action_kwargs "
"error_handler_enable "
"error_handler_func_name "
"error_handler_kwargs "
"job_function_id ",
)

Expand Down Expand Up @@ -79,6 +82,33 @@
"enable, func_name, kwargs.\n"
"See the module description for details.",
)
error_handler = JobSerialized(base_type=dict)
edit_error_handler = fields.Text(
string="Error Handler",
compute="_compute_edit_error_handler",
inverse="_inverse_edit_error_handler",
help="The handler is executed when the job fails. "
"Configured as a dictionary with optional keys: "
"enable, func_name, kwargs.\n"
"See the module description for details.",
)

@api.depends("error_handler")
def _compute_edit_error_handler(self):
for record in self:
record.edit_error_handler = str(record.error_handler)

Check warning on line 99 in queue_job/models/queue_job_function.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job_function.py#L99

Added line #L99 was not covered by tests

def _inverse_edit_error_handler(self):
try:
edited = (self.edit_error_handler or "").strip()

Check warning on line 103 in queue_job/models/queue_job_function.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job_function.py#L102-L103

Added lines #L102 - L103 were not covered by tests
if edited:
self.error_handler = ast.literal_eval(edited)

Check warning on line 105 in queue_job/models/queue_job_function.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job_function.py#L105

Added line #L105 was not covered by tests
else:
self.error_handler = {}
except (ValueError, TypeError, SyntaxError) as ex:
raise exceptions.UserError(

Check warning on line 109 in queue_job/models/queue_job_function.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job_function.py#L107-L109

Added lines #L107 - L109 were not covered by tests
self._error_handler_format_error_message()
) from ex

@api.depends("model_id.model", "method")
def _compute_name(self):
Expand Down Expand Up @@ -149,6 +179,9 @@
related_action_func_name=None,
related_action_kwargs={},
job_function_id=None,
error_handler_enable=True,
error_handler_func_name=None,
error_handler_kwargs={},
)

def _parse_retry_pattern(self):
Expand Down Expand Up @@ -182,6 +215,9 @@
related_action_func_name=config.related_action.get("func_name"),
related_action_kwargs=config.related_action.get("kwargs", {}),
job_function_id=config.id,
error_handler_enable=config.error_handler.get("enable", True),
error_handler_func_name=config.error_handler.get("func_name"),
error_handler_kwargs=config.error_handler.get("kwargs", {}),
)

def _retry_pattern_format_error_message(self):
Expand Down Expand Up @@ -215,6 +251,14 @@
' "kwargs" {{"limit": 10}}}}'
).format(self.name)

def _error_handler_format_error_message(self):
return _(

Check warning on line 255 in queue_job/models/queue_job_function.py

View check run for this annotation

Codecov / codecov/patch

queue_job/models/queue_job_function.py#L255

Added line #L255 was not covered by tests
"Unexpected format of Error Handler for {}.\n"
"Example of valid format:\n"
'{{"enable": True, "func_name": "_call_webhook",'
' "kwargs" {"webhook_url": "XXX","payload": {"text":"Hello World!"}}}}'
).format(self.name)

@api.constrains("related_action")
def _check_related_action(self):
valid_keys = ("enable", "func_name", "kwargs")
Expand Down
1 change: 1 addition & 0 deletions queue_job/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
* Souheil Bejaoui <[email protected]>
* Eric Antones <[email protected]>
* Simone Orsi <[email protected]>
* Tris Doan <[email protected]>
1 change: 1 addition & 0 deletions queue_job/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ Features:
description, number of retries
* Related Actions: link an action on the job view, such as open the record
concerned by the job
* Error Handler: trigger a method when job fails, such as calling a webhook
15 changes: 15 additions & 0 deletions queue_job/readme/USAGE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,21 @@ Based on this configuration, we can tell that:
* retries 10 to 15 postponed 30 seconds later
* all subsequent retries postponed 5 minutes later

**Job function: Error Handler**

The *Error Handler* is a method executed whenever the job fails

It's configured similarly to Related Action

There is an OOTB handler: _call_webhook, which calls a webhook with configurable information.

Example of using _call_webhook to call a webhook to Slack:

.. code-block:: xml

<field name="error_handler" eval='{"func_name": "_call_webhook", "kwargs": {"webhook_url": "XXX", "only_if_max_retries_reached":True, "payload": {"text": "Hello World!"}}}' />


**Job Context**

The context of the recordset of the job, or any recordset passed in arguments of
Expand Down
10 changes: 10 additions & 0 deletions queue_job/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ <h1 class="title">Job Queue</h1>
description, number of retries</li>
<li>Related Actions: link an action on the job view, such as open the record
concerned by the job</li>
<li>Error Handler: trigger a method when job fails, such as calling a webhook</li>
</ul>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
Expand Down Expand Up @@ -744,6 +745,14 @@ <h3><a class="toc-backref" href="#toc-entry-7">Configure default options for job
<li>retries 10 to 15 postponed 30 seconds later</li>
<li>all subsequent retries postponed 5 minutes later</li>
</ul>
<p><strong>Job function: Error Handler</strong></p>
<p>The <em>Error Handler</em> is a method executed whenever the job fails</p>
<p>It’s configured similarly to Related Action</p>
<p>There is an OOTB handler: _call_webhook, which calls a webhook with configurable information.</p>
<p>Example of using _call_webhook to call a webhook to Slack:</p>
<pre class="code xml literal-block">
<span class="nt">&lt;field</span><span class="w"> </span><span class="na">name=</span><span class="s">&quot;error_handler&quot;</span><span class="w"> </span><span class="na">eval=</span><span class="s">'{&quot;func_name&quot;: &quot;_call_webhook&quot;, &quot;kwargs&quot;: {&quot;webhook_url&quot;: &quot;XXX&quot;, &quot;only_if_max_retries_reached&quot;:True, &quot;payload&quot;: {&quot;text&quot;: &quot;Hello World!&quot;}}}'</span><span class="w"> </span><span class="nt">/&gt;</span>
</pre>
<p><strong>Job Context</strong></p>
<p>The context of the recordset of the job, or any recordset passed in arguments of
a job, is transferred to the job according to an allow-list.</p>
Expand Down Expand Up @@ -982,6 +991,7 @@ <h2><a class="toc-backref" href="#toc-entry-17">Contributors</a></h2>
<li>Souheil Bejaoui &lt;<a class="reference external" href="mailto:souheil.bejaoui&#64;acsone.eu">souheil.bejaoui&#64;acsone.eu</a>&gt;</li>
<li>Eric Antones &lt;<a class="reference external" href="mailto:eantones&#64;nuobit.com">eantones&#64;nuobit.com</a>&gt;</li>
<li>Simone Orsi &lt;<a class="reference external" href="mailto:simone.orsi&#64;camptocamp.com">simone.orsi&#64;camptocamp.com</a>&gt;</li>
<li>Tris Doan &lt;<a class="reference external" href="mailto:tridm&#64;trobz.com">tridm&#64;trobz.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
Expand Down
3 changes: 3 additions & 0 deletions queue_job/tests/test_model_job_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def test_function_job_config(self):
related_action_enable=True,
related_action_func_name="related_action_foo",
related_action_kwargs={"b": 1},
error_handler_enable=True,
error_handler_func_name=None,
error_handler_kwargs={},
job_function_id=job_function.id,
),
)
1 change: 1 addition & 0 deletions queue_job/views/queue_job_function_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<field name="channel_id" />
<field name="edit_retry_pattern" widget="ace" />
<field name="edit_related_action" widget="ace" />
<field name="edit_error_handler" widget="ace" />
</group>
</form>
</field>
Expand Down
1 change: 1 addition & 0 deletions test_queue_job/data/queue_job_function_data.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<field name="model_id" ref="test_queue_job.model_test_queue_job" />
<field name="method">testing_method</field>
<field name="related_action" eval='{"func_name": "testing_related_method"}' />
<field name="error_handler" eval='{"func_name": "testing_error_handler"}' />
</record>
<record
id="job_function_test_queue_job_job_with_retry_pattern"
Expand Down
3 changes: 3 additions & 0 deletions test_queue_job/models/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def testing_related__url(self, **kwargs):
"url": kwargs["url"].format(subject=subject),
}

def testing_error_handler(self, **kwargs):
return None


class ModelTestQueueJob(models.Model):

Expand Down
25 changes: 25 additions & 0 deletions test_queue_job/tests/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)

import hashlib
import logging
from datetime import datetime, timedelta
from unittest import mock
from unittest.mock import patch

import odoo.tests.common as common

from odoo.addons.queue_job import identity_exact
from odoo.addons.queue_job.controllers.main import RunJobController
from odoo.addons.queue_job.delay import DelayableGraph
from odoo.addons.queue_job.exception import (
FailedJobError,
Expand All @@ -24,9 +27,12 @@
WAIT_DEPENDENCIES,
Job,
)
from odoo.addons.queue_job.tests.common import trap_jobs

from .common import JobCommonCase

_logger = logging.getLogger(__name__)


class TestJobsOnTestingMethod(JobCommonCase):
"""Test Job"""
Expand Down Expand Up @@ -341,6 +347,25 @@
job1 = Job.load(self.env, test_job_1.uuid)
self.assertEqual(job1.identity_key, expected_key)

def test_failed_job_perform(self):
with trap_jobs() as trap:
model = self.env["test.queue.job"]
job = model.with_delay(priority=1, max_retries=1).testing_method()
trap.assert_jobs_count(1)
with patch.object(type(job), "perform", side_effect=IOError), patch(
"odoo.sql_db.Cursor.commit", return_value=None
): # avoid odoo.sql_db: bad query: ROLLBACK TO SAVEPOINT test_0
controller = RunJobController()
try:
controller._try_perform_job(self.env, job)
with patch(
"odoo.addons.test_queue_job.models.test_models.QueueJob"
".testing_error_handler"
) as patched:
patched.assert_called_once()

Check warning on line 365 in test_queue_job/tests/test_job.py

View check run for this annotation

Codecov / codecov/patch

test_queue_job/tests/test_job.py#L365

Added line #L365 was not covered by tests
except Exception:
_logger.info("Job fails")
Comment on lines +359 to +367
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that lines 361-365 are not executed. Test has to be improved here, I believe.

Extract of the log on CI:

2025-01-14 05:40:08,895 275 INFO odoo odoo.addons.test_queue_job.tests.test_job: Starting TestJobsOnTestingMethod.test_failed_job_perform ... 
2025-01-14 05:40:08,903 275 INFO odoo odoo.addons.queue_job.job: Job 648616de-b28e-4d33-8c3f-5be7ac760cac fails due to , execute <bound method QueueJob.testing_error_handler of queue.job()> 
2025-01-14 05:40:08,903 275 INFO odoo odoo.addons.test_queue_job.tests.test_job: Job fails



class TestJobs(JobCommonCase):
"""Test jobs on other methods or with different job configuration"""
Expand Down
Loading