Skip to content
Closed
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
1 change: 1 addition & 0 deletions edi_queue_oca/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from . import edi_exchange_record
from . import edi_exchange_type
from . import edi_backend
from . import queue_job
27 changes: 27 additions & 0 deletions edi_queue_oca/models/edi_exchange_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,33 @@ def delayable(self, **kw):
def _job_retry_params(self):
return {}

def _mark_failed_from_queue_job(self, job):
"""Set the EDI error matching a terminal queue job failure.

:param job: failed ``queue.job`` record
"""
failure_mapping = {
"action_exchange_process": ("input_processed_error", "process_ko"),
"action_exchange_receive": ("input_receive_error", "receive_ko"),
"action_exchange_send": ("output_error_on_send", "send_ko"),
}
failure = failure_mapping.get(job.method_name)
if not failure:
return
state, message_key = failure
for record in self:
state_changed = record.edi_exchange_state != state
record.write(
{
"edi_exchange_state": state,
"exchange_error": job.exc_message,
"exchange_error_traceback": job.exc_info,
"exchanged_on": fields.Datetime.now(),
}
)
if state_changed:
record._notify_error(message_key)

def _compute_related_queue_jobs_count(self):
for rec in self:
# TODO: We should refactor the object field on queue_job to use jsonb field
Expand Down
28 changes: 28 additions & 0 deletions edi_queue_oca/models/queue_job.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright 2026 Camptocamp SA
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).

from odoo import models


class QueueJob(models.Model):
_inherit = "queue.job"

def write(self, vals):
result = super().write(vals)
if vals.get("state") == "failed":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe there’s an opportunity to create an "on-failure" hook in the queue_job module

https://github.com/OCA/queue/blob/19.0/queue_job/models/queue_job.py#L268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FWIW when the job is failing, only the job object will be updated through a dedicated function for failure,
and then the modified values are going to be stored on the odoo record: https://github.com/OCA/queue/blob/19.0/queue_job/controllers/main.py#L187-L188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On fail hooks in queue job: OCA/queue#955

@Ricardoalso Ricardoalso Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Direct SQL updates executed by the queue_job runner will bypass this logic https://github.com/OCA/queue/blob/19.0/queue_job/jobrunner/runner.py#L217

In the situation where the number of retries exceeds the maximum allowed, the job is marked as "failed" with the error "JobFoundDead."

self._mark_related_edi_exchanges_failed()
return result

def _mark_related_edi_exchanges_failed(self):
"""Propagate terminal EDI job failures to their exchange records."""
supported_methods = {
"action_exchange_process",
"action_exchange_receive",
"action_exchange_send",
}
Comment on lines +18 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why don't we have action_exchange_generate

I'd suggest to have a constant variable with the functions list, so that it can be relied on here and in the _register_hook.

jobs = self.filtered(
lambda job: job.model_name == "edi.exchange.record"
and job.method_name in supported_methods
)
for job in jobs:
job.records.sudo()._mark_failed_from_queue_job(job)
108 changes: 107 additions & 1 deletion edi_queue_oca/tests/test_backend_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def test_output(self):
self.assertEqual(created, self._get_related_jobs(record))

def test_output_fail_retry(self):
"""Test a retryable send failure keeps the exchange pending."""
job_counter = self.job_counter()
vals = {
"model": self.partner._name,
Expand All @@ -106,8 +107,113 @@ def test_output_fail_retry(self):
job_counter.search_created()
with mock.patch.object(type(self.backend), "_exchange_send") as mocked:
mocked.side_effect = ReqConnectionError("Connection broken")
with self.assertRaises(RetryableJobError):
with self.assertRaisesRegex(RetryableJobError, "Connection broken"):
job.perform()
self.assertEqual(record.edi_exchange_state, "output_pending")

def test_failed_send_job_marks_exchange_as_error(self):
"""Test a terminal send job failure marks its exchange as failed."""
record = self.backend.create_record(
"test_csv_output",
{
"model": self.partner._name,
"res_id": self.partner.id,
"edi_exchange_state": "output_pending",
},
)
record._set_file_content("ABC")
job = record.with_delay().action_exchange_send()

job.db_record().write(
{
"state": "failed",
"exc_message": "Connection broken",
"exc_info": "Traceback of the connection failure",
}
)

self.assertEqual(record.edi_exchange_state, "output_error_on_send")
self.assertEqual(record.exchange_error, "Connection broken")
self.assertEqual(
record.exchange_error_traceback, "Traceback of the connection failure"
)
self.assertTrue(record.exchanged_on)

def test_failed_receive_job_marks_exchange_as_error(self):
"""Test a terminal receive job failure marks its exchange as failed."""
record = self.backend.create_record(
"test_csv_input",
{
"model": self.partner._name,
"res_id": self.partner.id,
"edi_exchange_state": "input_pending",
},
)
job = record.with_delay().action_exchange_receive()

job.db_record().write(
{
"state": "failed",
"exc_message": "Receive failed",
"exc_info": "Traceback for receive",
}
)

self.assertEqual(record.edi_exchange_state, "input_receive_error")
self.assertEqual(record.exchange_error, "Receive failed")
self.assertEqual(record.exchange_error_traceback, "Traceback for receive")

def test_failed_process_job_marks_exchange_as_error(self):
"""Test a terminal process job failure marks its exchange as failed."""
record = self.backend.create_record(
"test_csv_input",
{
"model": self.partner._name,
"res_id": self.partner.id,
"edi_exchange_state": "input_received",
},
)
job = record.with_delay().action_exchange_process()

job.db_record().write(
{
"state": "failed",
"exc_message": "Process failed",
"exc_info": "Traceback for process",
}
)

self.assertEqual(record.edi_exchange_state, "input_processed_error")
self.assertEqual(record.exchange_error, "Process failed")
self.assertEqual(record.exchange_error_traceback, "Traceback for process")

def test_unsupported_failed_jobs_do_not_mark_exchange_as_error(self):
"""Test generate and non-exchange jobs do not alter the exchange state."""
record = self.backend.create_record(
"test_csv_output",
{
"model": self.partner._name,
"res_id": self.partner.id,
"edi_exchange_state": "output_pending",
},
)
jobs = (
record.with_delay().action_exchange_generate(),
self.backend.with_delay().exchange_send(record),
)

for job in jobs:
job.db_record().write(
{
"state": "failed",
"exc_message": "Unsupported job failed",
"exc_info": "Unsupported job traceback",
}
)

self.assertEqual(record.edi_exchange_state, "output_pending")
self.assertFalse(record.exchange_error)
self.assertFalse(record.exchange_error_traceback)

def test_input(self):
job_counter = self.job_counter()
Expand Down
Loading