Skip to content

Commit 5d8cd0c

Browse files
committed
queue_job: extract controller's runjob as JobExecutor
1 parent d198792 commit 5d8cd0c

4 files changed

Lines changed: 267 additions & 207 deletions

File tree

queue_job/controllers/main.py

Lines changed: 16 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -4,208 +4,25 @@
44

55
import logging
66
import random
7-
import time
8-
import traceback
9-
from contextlib import contextmanager
10-
from io import StringIO
117

12-
from psycopg2 import OperationalError, errorcodes
138
from werkzeug.exceptions import BadRequest, Forbidden
149

15-
from odoo import SUPERUSER_ID, _, api, http
16-
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
17-
from odoo.tools import config
10+
from odoo import SUPERUSER_ID, _, http
1811

1912
from ..delay import chain, group
20-
from ..exception import FailedJobError, RetryableJobError
21-
from ..job import ENQUEUED, Job
2213

23-
_logger = logging.getLogger(__name__)
24-
25-
PG_RETRY = 5 # seconds
26-
27-
DEPENDS_MAX_TRIES_ON_CONCURRENCY_FAILURE = 5
28-
29-
30-
@contextmanager
31-
def _prevent_commit(cr):
32-
"""Context manager to prevent commits on a cursor.
33-
34-
Commiting while the job is not finished would release the job lock, causing
35-
it to be started again by the dead jobs requeuer.
36-
"""
37-
38-
def forbidden_commit(*args, **kwargs):
39-
raise RuntimeError(
40-
"Commit is forbidden in queue jobs. "
41-
'You may want to enable the "Allow Commit" option on the Job '
42-
"Function. Alternatively, if the current job is a cron running as "
43-
"queue job, you can modify it to run as a normal cron. More details on: "
44-
"https://github.com/OCA/queue/wiki/Upgrade-warning:-commits-inside-jobs"
45-
)
14+
# unused imports are kept for backward compatibility
15+
from ..executor import (
16+
DEPENDS_MAX_TRIES_ON_CONCURRENCY_FAILURE, # noqa: F401
17+
PG_RETRY, # noqa: F401
18+
JobExecutor,
19+
_prevent_commit, # noqa: F401
20+
)
4621

47-
original_commit = cr.commit
48-
cr.commit = forbidden_commit
49-
try:
50-
yield
51-
finally:
52-
cr.commit = original_commit
22+
_logger = logging.getLogger(__name__)
5323

5424

5525
class RunJobController(http.Controller):
56-
@classmethod
57-
def _acquire_job(cls, env: api.Environment, job_uuid: str) -> Job | None:
58-
"""Acquire a job for execution.
59-
60-
- make sure it is in ENQUEUED state
61-
- mark it as STARTED and commit the state change
62-
- acquire the job lock
63-
64-
If successful, return the Job instance, otherwise return None. This
65-
function may fail to acquire the job is not in the expected state or is
66-
already locked by another worker.
67-
"""
68-
env.cr.execute(
69-
"SELECT uuid FROM queue_job WHERE uuid=%s AND state=%s "
70-
"FOR NO KEY UPDATE SKIP LOCKED",
71-
(job_uuid, ENQUEUED),
72-
)
73-
if not env.cr.fetchone():
74-
_logger.warning(
75-
"was requested to run job %s, but it does not exist, "
76-
"or is not in state %s, or is being handled by another worker",
77-
job_uuid,
78-
ENQUEUED,
79-
)
80-
return None
81-
job = Job.load(env, job_uuid)
82-
assert job and job.state == ENQUEUED
83-
job.set_started()
84-
job.store()
85-
env.cr.commit()
86-
if not job.lock():
87-
_logger.warning(
88-
"was requested to run job %s, but it could not be locked",
89-
job_uuid,
90-
)
91-
return None
92-
return job
93-
94-
@classmethod
95-
def _try_perform_job(cls, env, job):
96-
"""Try to perform the job, mark it done and commit if successful."""
97-
_logger.debug("%s started", job)
98-
# TODO refactor, the relation between env and job.env is not clear
99-
assert env.cr is job.env.cr
100-
with _prevent_commit(env.cr):
101-
job.perform()
102-
# Triggers any stored computed fields before calling 'set_done'
103-
# so that will be part of the 'exec_time'
104-
env.flush_all()
105-
job.set_done()
106-
job.store()
107-
env.flush_all()
108-
if not config["test_enable"]:
109-
env.cr.commit()
110-
_logger.debug("%s done", job)
111-
112-
@classmethod
113-
def _enqueue_dependent_jobs(cls, env, job):
114-
if not job.should_check_dependents():
115-
return
116-
117-
_logger.debug("%s enqueue depends started", job)
118-
tries = 0
119-
while True:
120-
try:
121-
with job.env.cr.savepoint():
122-
job.enqueue_waiting()
123-
except OperationalError as err:
124-
# Automatically retry the typical transaction serialization
125-
# errors
126-
if err.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY:
127-
raise
128-
if tries >= DEPENDS_MAX_TRIES_ON_CONCURRENCY_FAILURE:
129-
_logger.error(
130-
"%s, maximum number of tries reached to update dependencies",
131-
errorcodes.lookup(err.pgcode),
132-
)
133-
raise
134-
wait_time = random.uniform(0.0, 2**tries)
135-
tries += 1
136-
_logger.info(
137-
"%s, retry %d/%d in %.04f sec...",
138-
errorcodes.lookup(err.pgcode),
139-
tries,
140-
DEPENDS_MAX_TRIES_ON_CONCURRENCY_FAILURE,
141-
wait_time,
142-
)
143-
time.sleep(wait_time)
144-
else:
145-
break
146-
_logger.debug("%s enqueue depends done", job)
147-
148-
@classmethod
149-
def _runjob(cls, env: api.Environment, job: Job) -> None:
150-
def retry_postpone(job, message, seconds=None):
151-
job.env.clear()
152-
with job.in_temporary_env():
153-
job.postpone(result=message, seconds=seconds)
154-
job.set_pending(reset_retry=False)
155-
job.store()
156-
157-
try:
158-
try:
159-
cls._try_perform_job(env, job)
160-
except OperationalError as err:
161-
# Automatically retry the typical transaction serialization
162-
# errors
163-
if err.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY:
164-
raise
165-
166-
_logger.debug("%s OperationalError, postponed", job)
167-
raise RetryableJobError(err.pgerror, seconds=PG_RETRY) from err
168-
169-
except RetryableJobError as err:
170-
# delay the job later, requeue
171-
retry_postpone(job, str(err), seconds=err.seconds)
172-
_logger.debug("%s postponed", job)
173-
# Do not trigger the error up because we don't want an exception
174-
# traceback in the logs we should have the traceback when all
175-
# retries are exhausted
176-
env.cr.rollback()
177-
return
178-
179-
except (FailedJobError, Exception) as orig_exception:
180-
buff = StringIO()
181-
traceback.print_exc(file=buff)
182-
traceback_txt = buff.getvalue()
183-
_logger.error(traceback_txt)
184-
job.env.clear()
185-
with job.in_temporary_env():
186-
vals = cls._get_failure_values(job, traceback_txt, orig_exception)
187-
job.set_failed(**vals)
188-
job.store()
189-
buff.close()
190-
raise
191-
192-
cls._enqueue_dependent_jobs(env, job)
193-
194-
@classmethod
195-
def _get_failure_values(cls, job, traceback_txt, orig_exception):
196-
"""Collect relevant data from exception."""
197-
exception_name = orig_exception.__class__.__name__
198-
if hasattr(orig_exception, "__module__"):
199-
exception_name = orig_exception.__module__ + "." + exception_name
200-
exc_message = (
201-
orig_exception.args[0] if orig_exception.args else str(orig_exception)
202-
)
203-
return {
204-
"exc_info": traceback_txt,
205-
"exc_name": exception_name,
206-
"exc_message": exc_message,
207-
}
208-
20926
@http.route(
21027
"/queue_job/runjob",
21128
type="http",
@@ -215,11 +32,13 @@ def _get_failure_values(cls, job, traceback_txt, orig_exception):
21532
)
21633
def runjob(self, db, job_uuid, **kw):
21734
http.request.session.db = db
218-
env = http.request.env(user=SUPERUSER_ID)
219-
job = self._acquire_job(env, job_uuid)
220-
if not job:
221-
return ""
222-
self._runjob(env, job)
35+
# update_env (in contrast to a local request.env(user=...)) replaces
36+
# the uid=None environment installed by auth="none" on the request
37+
# itself. On Odoo >= 19 it additionally repoints
38+
# transaction.default_env, which otherwise makes flushes recompute
39+
# stored fields with uid=None (see OCA/queue issue #922)
40+
http.request.update_env(user=SUPERUSER_ID)
41+
JobExecutor(http.request.env, job_uuid).run()
22342
return ""
22443

22544
# flake8: noqa: C901

0 commit comments

Comments
 (0)