Skip to content

Commit db9e0e7

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

7 files changed

Lines changed: 164 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
from unittest import mock
77

88
import odoo.tests.common as common
9+
from odoo.tests.common import mute_logger
910

1011
from odoo.addons.queue_job import identity_exact
1112
from odoo.addons.queue_job.delay import DelayableGraph
1213
from odoo.addons.queue_job.exception import (
1314
FailedJobError,
15+
JobMethodNotFound,
1416
NoSuchJobError,
1517
RetryableJobError,
1618
)
@@ -304,6 +306,33 @@ def test_job_unlinked(self):
304306
with self.assertRaises(NoSuchJobError):
305307
Job.load(self.env, test_job.uuid)
306308

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

603+
def test_change_state_skips_missing_method(self):
604+
"""_change_job_state does not crash when the method is missing."""
605+
stored = self._create_job()
606+
self.env.cr.execute(
607+
"UPDATE queue_job SET method_name = %s WHERE uuid = %s",
608+
("vanished_xyz", stored.uuid),
609+
)
610+
self.env["queue.job"].invalidate_model()
611+
stored.button_done()
612+
574613
def test_requeue_wait_dependencies_not_touched(self):
575614
job_root = Job(self.env["test.queue.job"].testing_method)
576615
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)