44
55import logging
66import random
7- import time
8- import traceback
9- from contextlib import contextmanager
10- from io import StringIO
117
12- from psycopg2 import OperationalError , errorcodes
138from 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
1912from ..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
5525class 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