Skip to content

Commit 9a57664

Browse files
committed
Merge PR #866 into 18.0
Signed-off-by sbidoul
2 parents 7b72339 + ea803f3 commit 9a57664

6 files changed

Lines changed: 133 additions & 71 deletions

File tree

queue_job/controllers/main.py

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,48 @@
2727

2828

2929
class RunJobController(http.Controller):
30-
def _try_perform_job(self, env, job):
31-
"""Try to perform the job."""
30+
@classmethod
31+
def _acquire_job(cls, env: api.Environment, job_uuid: str) -> Job | None:
32+
"""Acquire a job for execution.
33+
34+
- make sure it is in ENQUEUED state
35+
- mark it as STARTED and commit the state change
36+
- acquire the job lock
37+
38+
If successful, return the Job instance, otherwise return None. This
39+
function may fail to acquire the job is not in the expected state or is
40+
already locked by another worker.
41+
"""
42+
env.cr.execute(
43+
"SELECT uuid FROM queue_job WHERE uuid=%s AND state=%s "
44+
"FOR UPDATE SKIP LOCKED",
45+
(job_uuid, ENQUEUED),
46+
)
47+
if not env.cr.fetchone():
48+
_logger.warning(
49+
"was requested to run job %s, but it does not exist, "
50+
"or is not in state %s, or is being handled by another worker",
51+
job_uuid,
52+
ENQUEUED,
53+
)
54+
return None
55+
job = Job.load(env, job_uuid)
56+
assert job and job.state == ENQUEUED
3257
job.set_started()
3358
job.store()
3459
env.cr.commit()
35-
job.lock()
60+
if not job.lock():
61+
_logger.warning(
62+
"was requested to run job %s, but it could not be locked",
63+
job_uuid,
64+
)
65+
return None
66+
return job
3667

68+
@classmethod
69+
def _try_perform_job(cls, env, job):
70+
"""Try to perform the job, mark it done and commit if successful."""
3771
_logger.debug("%s started", job)
38-
3972
job.perform()
4073
# Triggers any stored computed fields before calling 'set_done'
4174
# so that will be part of the 'exec_time'
@@ -46,7 +79,8 @@ def _try_perform_job(self, env, job):
4679
env.cr.commit()
4780
_logger.debug("%s done", job)
4881

49-
def _enqueue_dependent_jobs(self, env, job):
82+
@classmethod
83+
def _enqueue_dependent_jobs(cls, env, job):
5084
tries = 0
5185
while True:
5286
try:
@@ -76,17 +110,8 @@ def _enqueue_dependent_jobs(self, env, job):
76110
else:
77111
break
78112

79-
@http.route(
80-
"/queue_job/runjob",
81-
type="http",
82-
auth="none",
83-
save_session=False,
84-
readonly=False,
85-
)
86-
def runjob(self, db, job_uuid, **kw):
87-
http.request.session.db = db
88-
env = http.request.env(user=SUPERUSER_ID)
89-
113+
@classmethod
114+
def _runjob(cls, env: api.Environment, job: Job) -> None:
90115
def retry_postpone(job, message, seconds=None):
91116
job.env.clear()
92117
with Registry(job.env.cr.dbname).cursor() as new_cr:
@@ -95,26 +120,9 @@ def retry_postpone(job, message, seconds=None):
95120
job.set_pending(reset_retry=False)
96121
job.store()
97122

98-
# ensure the job to run is in the correct state and lock the record
99-
env.cr.execute(
100-
"SELECT state FROM queue_job WHERE uuid=%s AND state=%s FOR UPDATE",
101-
(job_uuid, ENQUEUED),
102-
)
103-
if not env.cr.fetchone():
104-
_logger.warning(
105-
"was requested to run job %s, but it does not exist, "
106-
"or is not in state %s",
107-
job_uuid,
108-
ENQUEUED,
109-
)
110-
return ""
111-
112-
job = Job.load(env, job_uuid)
113-
assert job and job.state == ENQUEUED
114-
115123
try:
116124
try:
117-
self._try_perform_job(env, job)
125+
cls._try_perform_job(env, job)
118126
except OperationalError as err:
119127
# Automatically retry the typical transaction serialization
120128
# errors
@@ -132,7 +140,6 @@ def retry_postpone(job, message, seconds=None):
132140
# traceback in the logs we should have the traceback when all
133141
# retries are exhausted
134142
env.cr.rollback()
135-
return ""
136143

137144
except (FailedJobError, Exception) as orig_exception:
138145
buff = StringIO()
@@ -142,19 +149,18 @@ def retry_postpone(job, message, seconds=None):
142149
job.env.clear()
143150
with Registry(job.env.cr.dbname).cursor() as new_cr:
144151
job.env = job.env(cr=new_cr)
145-
vals = self._get_failure_values(job, traceback_txt, orig_exception)
152+
vals = cls._get_failure_values(job, traceback_txt, orig_exception)
146153
job.set_failed(**vals)
147154
job.store()
148155
buff.close()
149156
raise
150157

151158
_logger.debug("%s enqueue depends started", job)
152-
self._enqueue_dependent_jobs(env, job)
159+
cls._enqueue_dependent_jobs(env, job)
153160
_logger.debug("%s enqueue depends done", job)
154161

155-
return ""
156-
157-
def _get_failure_values(self, job, traceback_txt, orig_exception):
162+
@classmethod
163+
def _get_failure_values(cls, job, traceback_txt, orig_exception):
158164
"""Collect relevant data from exception."""
159165
exception_name = orig_exception.__class__.__name__
160166
if hasattr(orig_exception, "__module__"):
@@ -168,6 +174,22 @@ def _get_failure_values(self, job, traceback_txt, orig_exception):
168174
"exc_message": exc_message,
169175
}
170176

177+
@http.route(
178+
"/queue_job/runjob",
179+
type="http",
180+
auth="none",
181+
save_session=False,
182+
readonly=False,
183+
)
184+
def runjob(self, db, job_uuid, **kw):
185+
http.request.session.db = db
186+
env = http.request.env(user=SUPERUSER_ID)
187+
job = self._acquire_job(env, job_uuid)
188+
if not job:
189+
return ""
190+
self._runjob(env, job)
191+
return ""
192+
171193
# flake8: noqa: C901
172194
@http.route("/queue_job/create_test_job", type="http", auth="user")
173195
def create_test_job(

queue_job/job.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ def load_many(cls, env, job_uuids):
221221
recordset = cls.db_records_from_uuids(env, job_uuids)
222222
return {cls._load_from_db_record(record) for record in recordset}
223223

224-
def add_lock_record(self):
224+
def add_lock_record(self) -> None:
225225
"""
226226
Create row in db to be locked while the job is being performed.
227227
"""
@@ -241,13 +241,11 @@ def add_lock_record(self):
241241
[self.uuid],
242242
)
243243

244-
def lock(self):
245-
"""
246-
Lock row of job that is being performed
244+
def lock(self) -> bool:
245+
"""Lock row of job that is being performed.
247246
248-
If a job cannot be locked,
249-
it means that the job wasn't started,
250-
a RetryableJobError is thrown.
247+
Return False if a job cannot be locked: it means that the job is not in
248+
STARTED state or is already locked by another worker.
251249
"""
252250
self.env.cr.execute(
253251
"""
@@ -263,18 +261,15 @@ def lock(self):
263261
queue_job
264262
WHERE
265263
uuid = %s
266-
AND state='started'
264+
AND state = %s
267265
)
268-
FOR UPDATE;
266+
FOR UPDATE SKIP LOCKED;
269267
""",
270-
[self.uuid],
268+
[self.uuid, STARTED],
271269
)
272270

273271
# 1 job should be locked
274-
if 1 != len(self.env.cr.fetchall()):
275-
raise RetryableJobError(
276-
f"Trying to lock job that wasn't started, uuid: {self.uuid}"
277-
)
272+
return bool(self.env.cr.fetchall())
278273

279274
@classmethod
280275
def _load_from_db_record(cls, job_db_record):

test_queue_job/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from . import test_acquire_job
12
from . import test_autovacuum
23
from . import test_delayable
34
from . import test_dependencies

test_queue_job/tests/common.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,13 @@ def _create_job(self):
2020
stored = Job.db_records_from_uuids(self.env, [test_job.uuid])
2121
self.assertEqual(len(stored), 1)
2222
return stored
23+
24+
def _get_demo_job(self, uuid):
25+
# job created during load of demo data
26+
job = self.env["queue.job"].search([("uuid", "=", uuid)], limit=1)
27+
self.assertTrue(
28+
job,
29+
f"Demo data queue job {uuid!r} should be loaded in order "
30+
"to make this test work",
31+
)
32+
return job
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2026 ACSONE SA/NV
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
import logging
4+
from unittest import mock
5+
6+
from odoo.tests import tagged
7+
8+
from odoo.addons.queue_job.controllers.main import RunJobController
9+
10+
from .common import JobCommonCase
11+
12+
13+
@tagged("post_install", "-at_install")
14+
class TestRequeueDeadJob(JobCommonCase):
15+
def test_acquire_enqueued_job(self):
16+
job_record = self._get_demo_job(uuid="test_enqueued_job")
17+
self.assertFalse(
18+
self.env["queue.job.lock"].search(
19+
[("queue_job_id", "=", job_record.id)],
20+
),
21+
"A job lock record should not exist at this point",
22+
)
23+
with mock.patch.object(
24+
self.env.cr, "commit", mock.Mock(side_effect=self.env.flush_all)
25+
) as mock_commit:
26+
job = RunJobController._acquire_job(self.env, job_uuid="test_enqueued_job")
27+
mock_commit.assert_called_once()
28+
self.assertIsNotNone(job)
29+
self.assertEqual(job.uuid, "test_enqueued_job")
30+
self.assertEqual(job.state, "started")
31+
self.assertTrue(
32+
self.env["queue.job.lock"].search(
33+
[("queue_job_id", "=", job_record.id)]
34+
),
35+
"A job lock record should exist at this point",
36+
)
37+
38+
def test_acquire_started_job(self):
39+
with (
40+
mock.patch.object(
41+
self.env.cr, "commit", mock.Mock(side_effect=self.env.flush_all)
42+
) as mock_commit,
43+
self.assertLogs(level=logging.WARNING) as logs,
44+
):
45+
job = RunJobController._acquire_job(self.env, "test_started_job")
46+
mock_commit.assert_not_called()
47+
self.assertIsNone(job)
48+
self.assertIn(
49+
"was requested to run job test_started_job, but it does not exist",
50+
logs.output[0],
51+
)

test_queue_job/tests/test_requeue_dead_job.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,6 @@
1313

1414
@tagged("post_install", "-at_install")
1515
class TestRequeueDeadJob(JobCommonCase):
16-
def _get_demo_job(self, uuid):
17-
# job created during load of demo data
18-
job = self.env["queue.job"].search(
19-
[
20-
("uuid", "=", uuid),
21-
],
22-
limit=1,
23-
)
24-
25-
self.assertTrue(
26-
job,
27-
f"Demo data queue job {uuid} should be loaded in order"
28-
" to make this tests work",
29-
)
30-
31-
return job
32-
3316
def get_locks(self, uuid, cr=None):
3417
"""
3518
Retrieve lock rows

0 commit comments

Comments
 (0)