Skip to content

Commit 850506f

Browse files
committed
[IMP] queue_job: handle non-existing method job
1 parent 55657f1 commit 850506f

7 files changed

Lines changed: 162 additions & 9 deletions

File tree

queue_job/controllers/main.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from odoo.tools import config
1818

1919
from ..delay import chain, group
20-
from ..exception import FailedJobError, RetryableJobError
20+
from ..exception import FailedJobError, JobMethodNotFound, RetryableJobError
2121
from ..job import ENQUEUED, Job
2222

2323
_logger = logging.getLogger(__name__)
@@ -78,7 +78,29 @@ def _acquire_job(cls, env: api.Environment, job_uuid: str) -> Job | None:
7878
ENQUEUED,
7979
)
8080
return None
81-
job = Job.load(env, job_uuid)
81+
try:
82+
job = Job.load(env, job_uuid)
83+
except JobMethodNotFound as exc:
84+
# In case a job's method no longer exists, we don't want the runner
85+
# to keep re-enqueuing it. Failing it here with exceptions. We use
86+
# raw SQL here to avoid this update being rolled back due to savepoint.
87+
exc_name = f"{exc.__class__.__module__}.{exc.__class__.__name__}"
88+
exc_message = str(exc)
89+
env.cr.execute(
90+
"""
91+
UPDATE queue_job
92+
SET state = 'failed', exc_name = %s, exc_message = %s, exc_info = %s
93+
WHERE uuid = %s AND state NOT IN ('done', 'cancelled', 'failed')
94+
""",
95+
(exc_name, exc_message, exc_message, job_uuid),
96+
)
97+
_logger.warning(
98+
"Job %s references a non-existent method and was marked as failed",
99+
job_uuid,
100+
)
101+
if not config["test_enable"]:
102+
env.cr.commit()
103+
return None
82104
assert job and job.state == ENQUEUED
83105
job.set_started()
84106
job.store()

queue_job/exception.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ class FailedJobError(JobError):
1818
"""A job had an error having to be resolved."""
1919

2020

21+
class JobMethodNotFound(FailedJobError):
22+
"""The job's target method no longer exists on the model."""
23+
24+
def __init__(self, model_name, method_name):
25+
self.model_name = model_name
26+
self.method_name = method_name
27+
super().__init__(
28+
f"Method '{method_name}' does not exist on model '{model_name}'."
29+
f" The job function may have been removed or the module providing"
30+
f" it was uninstalled after this job was created."
31+
)
32+
33+
2134
class RetryableJobError(JobError):
2235
"""A job had an error but can be retried.
2336

queue_job/job.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414

1515
import odoo
1616

17-
from .exception import FailedJobError, NoSuchJobError, RetryableJobError
17+
from .exception import (
18+
FailedJobError,
19+
JobMethodNotFound,
20+
NoSuchJobError,
21+
RetryableJobError,
22+
)
1823

1924
WAIT_DEPENDENCIES = "wait_dependencies"
2025
PENDING = "pending"
@@ -217,10 +222,20 @@ def load(cls, env, job_uuid):
217222
def load_many(cls, env, job_uuids):
218223
"""Read jobs in batch from the Database
219224
220-
Jobs not found are ignored.
225+
Jobs not found are ignored. Jobs whose method no longer exists are also
226+
skipped with a warning.
221227
"""
222228
recordset = cls.db_records_from_uuids(env, job_uuids)
223-
return {cls._load_from_db_record(record) for record in recordset}
229+
jobs = set()
230+
for record in recordset:
231+
try:
232+
jobs.add(cls._load_from_db_record(record))
233+
except JobMethodNotFound:
234+
_logger.warning(
235+
"Skipping job %s as method no longer exists",
236+
record.uuid,
237+
)
238+
return jobs
224239

225240
def add_lock_record(self) -> None:
226241
"""
@@ -281,7 +296,9 @@ def _load_from_db_record(cls, job_db_record):
281296
method_name = stored.method_name
282297

283298
recordset = stored.records
284-
method = getattr(recordset, method_name)
299+
method = getattr(recordset, method_name, None)
300+
if method is None:
301+
raise JobMethodNotFound(recordset._name, method_name)
285302

286303
eta = None
287304
if stored.eta:

queue_job/models/queue_job.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from odoo.addons.base_sparse_field.models.fields import Serialized
1313

1414
from ..delay import Graph
15-
from ..exception import JobError, RetryableJobError
15+
from ..exception import JobError, JobMethodNotFound, RetryableJobError
1616
from ..fields import JobSerialized
1717
from ..job import (
1818
CANCELLED,
@@ -279,7 +279,15 @@ def write(self, vals):
279279
def open_related_action(self):
280280
"""Open the related action associated to the job"""
281281
self.ensure_one()
282-
job = Job.load(self.env, self.uuid)
282+
try:
283+
job = Job.load(self.env, self.uuid)
284+
except JobMethodNotFound as exc:
285+
raise exceptions.UserError(
286+
_(
287+
"The job function is no longer available: %s",
288+
exc,
289+
)
290+
) from exc
283291
action = job.related_action()
284292
if action is None:
285293
raise exceptions.UserError(_("No action available for this job"))
@@ -309,7 +317,10 @@ def _change_job_state(self, state, result=None):
309317
(date, result, ...).
310318
"""
311319
for record in self:
312-
job_ = Job.load(record.env, record.uuid)
320+
try:
321+
job_ = Job.load(record.env, record.uuid)
322+
except JobMethodNotFound:
323+
continue
313324
if state == DONE:
314325
job_.set_done(result=result)
315326
job_.store()

test_queue_job/tests/test_acquire_job.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from unittest import mock
55

66
from odoo.tests import tagged
7+
from odoo.tests.common import mute_logger
78

89
from odoo.addons.queue_job.controllers.main import RunJobController
10+
from odoo.addons.queue_job.job import Job
911

1012
from .common import JobCommonCase
1113

@@ -49,3 +51,43 @@ def test_acquire_started_job(self):
4951
"was requested to run job test_started_job, but it does not exist",
5052
logs.output[0],
5153
)
54+
55+
def _create_non_existing_method_job(self):
56+
test_job = Job(self.method)
57+
test_job.store()
58+
self.env.cr.execute(
59+
"""
60+
UPDATE queue_job SET state = 'enqueued', method_name = %s
61+
WHERE uuid = %s
62+
""",
63+
("nonexistent_xyz", test_job.uuid),
64+
)
65+
self.env["queue.job"].invalidate_model()
66+
return test_job.uuid
67+
68+
def test_acquire_returns_none_when_method_missing(self):
69+
uuid = self._create_non_existing_method_job()
70+
with (
71+
mock.patch.object(
72+
self.env.cr, "commit", mock.Mock(side_effect=self.env.flush_all)
73+
),
74+
mute_logger("odoo.addons.queue_job.controllers.main"),
75+
):
76+
job = RunJobController._acquire_job(self.env, uuid)
77+
self.assertIsNone(job)
78+
79+
def test_acquire_marks_job_failed_when_method_missing(self):
80+
uuid = self._create_non_existing_method_job()
81+
with (
82+
mock.patch.object(
83+
self.env.cr, "commit", mock.Mock(side_effect=self.env.flush_all)
84+
),
85+
mute_logger("odoo.addons.queue_job.controllers.main"),
86+
):
87+
RunJobController._acquire_job(self.env, uuid)
88+
self.env.cr.execute(
89+
"SELECT state, exc_message FROM queue_job WHERE uuid = %s", (uuid,)
90+
)
91+
state, exc_message = self.env.cr.fetchone()
92+
self.assertEqual(state, "failed")
93+
self.assertIn("nonexistent_xyz", exc_message)

test_queue_job/tests/test_job.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from odoo.addons.queue_job.delay import DelayableGraph
1212
from odoo.addons.queue_job.exception import (
1313
FailedJobError,
14+
JobMethodNotFound,
1415
NoSuchJobError,
1516
RetryableJobError,
1617
)
@@ -304,6 +305,32 @@ def test_job_unlinked(self):
304305
with self.assertRaises(NoSuchJobError):
305306
Job.load(self.env, test_job.uuid)
306307

308+
def _create_non_existing_method_job(self):
309+
test_job = Job(self.method)
310+
test_job.store()
311+
self.env.cr.execute(
312+
"UPDATE queue_job SET method_name = %s WHERE uuid = %s",
313+
("nonexistent_xyz", test_job.uuid),
314+
)
315+
self.env["queue.job"].invalidate_model()
316+
return test_job.uuid
317+
318+
def test_load_missing_method(self):
319+
"""Job.load raises JobMethodNotFound when the method is missing."""
320+
uuid = self._create_non_existing_method_job()
321+
with self.assertRaises(JobMethodNotFound):
322+
Job.load(self.env, uuid)
323+
324+
def test_load_many_skips_missing_method(self):
325+
"""load_many skips a broken job — other jobs still load."""
326+
good = Job(self.method)
327+
good.store()
328+
bad_uuid = self._create_non_existing_method_job()
329+
loaded = Job.load_many(self.env, [good.uuid, bad_uuid])
330+
loaded_uuids = {j.uuid for j in loaded}
331+
self.assertIn(good.uuid, loaded_uuids)
332+
self.assertNotIn(bad_uuid, loaded_uuids)
333+
307334
def test_unicode(self):
308335
test_job = Job(
309336
self.method,
@@ -571,6 +598,16 @@ def test_requeue(self):
571598
stored.requeue()
572599
self.assertEqual(stored.state, PENDING)
573600

601+
def test_change_state_skips_missing_method(self):
602+
"""_change_job_state does not crash when the method is missing."""
603+
stored = self._create_job()
604+
self.env.cr.execute(
605+
"UPDATE queue_job SET method_name = %s WHERE uuid = %s",
606+
("vanished_xyz", stored.uuid),
607+
)
608+
self.env["queue.job"].invalidate_model()
609+
stored.button_done()
610+
574611
def test_requeue_wait_dependencies_not_touched(self):
575612
job_root = Job(self.env["test.queue.job"].testing_method)
576613
job_child = Job(self.env["test.queue.job"].testing_method)

test_queue_job/tests/test_related_actions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,14 @@ def test_decorator(self):
112112
"url": "https://en.wikipedia.org/wiki/Discworld",
113113
}
114114
self.assertEqual(job_.related_action(), expected)
115+
116+
def test_open_related_action_missing_method(self):
117+
job_ = self.model.with_delay().testing_related_action__no()
118+
stored = job_.db_record()
119+
self.env.cr.execute(
120+
"UPDATE queue_job SET method_name = %s WHERE uuid = %s",
121+
("gone_xyz", job_.uuid),
122+
)
123+
self.env["queue.job"].invalidate_model()
124+
with self.assertRaises(exceptions.UserError):
125+
stored.open_related_action()

0 commit comments

Comments
 (0)