Skip to content

Commit 43b27cb

Browse files
[ADD] queue_job_cron_jobrunner
1 parent 426e512 commit 43b27cb

File tree

16 files changed

+338
-0
lines changed

16 files changed

+338
-0
lines changed

queue_job_cron_jobrunner/README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
TO BE GENERATED AUTOMATICALLY

queue_job_cron_jobrunner/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "Queue Job Cron Jobrunner",
3+
"summary": "Run jobs without a dedicated JobRunner",
4+
"version": "15.0.1.0.0",
5+
"development_status": "Alpha",
6+
"author": "Camptocamp SA, Odoo Community Association (OCA)",
7+
"maintainers": ["ivantodorovich"],
8+
"website": "https://github.com/OCA/queue",
9+
"license": "AGPL-3",
10+
"category": "Others",
11+
"depends": ["queue_job"],
12+
"data": [
13+
"data/ir_cron.xml",
14+
"views/ir_cron.xml",
15+
],
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<odoo noupdate="1">
3+
4+
<record id="queue_job_cron" model="ir.cron">
5+
<field name="name">Queue Job Runner</field>
6+
<field name="model_id" ref="queue_job.model_queue_job" />
7+
<field name="state">code</field>
8+
<field name="code">model._job_runner()</field>
9+
<field name="queue_job_runner" eval="True" />
10+
<field name="user_id" ref="base.user_root" />
11+
<field name="interval_number">1</field>
12+
<field name="interval_type">days</field>
13+
<field name="numbercall">-1</field>
14+
</record>
15+
16+
</odoo>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import ir_cron
2+
from . import queue_job
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
2+
# @author Iván Todorovich <[email protected]>
3+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
4+
5+
from odoo import fields, models
6+
7+
8+
class IrCron(models.Model):
9+
_inherit = "ir.cron"
10+
11+
queue_job_runner = fields.Boolean(
12+
help="If checked, the cron is considered to be a queue.job runner.",
13+
)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
2+
# @author Iván Todorovich <[email protected]>
3+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
4+
5+
import logging
6+
import traceback
7+
from io import StringIO
8+
9+
from psycopg2 import OperationalError
10+
11+
from odoo import _, api, models, tools
12+
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
13+
14+
from odoo.addons.queue_job.controllers.main import PG_RETRY
15+
from odoo.addons.queue_job.exception import (
16+
FailedJobError,
17+
NothingToDoJob,
18+
RetryableJobError,
19+
)
20+
from odoo.addons.queue_job.job import Job
21+
22+
_logger = logging.getLogger(__name__)
23+
24+
25+
class QueueJob(models.Model):
26+
_inherit = "queue.job"
27+
28+
@api.model
29+
def _acquire_one_job(self):
30+
"""Acquire the next job to be run.
31+
32+
:returns: queue.job record (locked for update)
33+
"""
34+
# TODO: This method should respect channel priority and capacity,
35+
# rather than just fetching them by creation date.
36+
self.flush()
37+
self.env.cr.execute(
38+
"""
39+
SELECT id
40+
FROM queue_job
41+
WHERE state = 'pending'
42+
AND (eta IS NULL OR eta <= (now() AT TIME ZONE 'UTC'))
43+
ORDER BY date_created DESC
44+
LIMIT 1 FOR NO KEY UPDATE SKIP LOCKED
45+
"""
46+
)
47+
row = self.env.cr.fetchone()
48+
return self.browse(row and row[0])
49+
50+
def _process(self, commit=False):
51+
"""Process the job"""
52+
self.ensure_one()
53+
job = Job._load_from_db_record(self)
54+
# Set it as started
55+
job.set_started()
56+
job.store()
57+
_logger.debug("%s started", job.uuid)
58+
# TODO: Commit the state change so that the state can be read from the UI
59+
# while the job is processing. However, doing this will release the
60+
# lock on the db, so we need to find another way.
61+
# if commit:
62+
# self.flush()
63+
# self.env.cr.commit()
64+
65+
# Actual processing
66+
try:
67+
try:
68+
with self.env.cr.savepoint():
69+
job.perform()
70+
job.set_done()
71+
job.store()
72+
except OperationalError as err:
73+
# Automatically retry the typical transaction serialization errors
74+
if err.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY:
75+
raise
76+
message = tools.ustr(err.pgerror, errors="replace")
77+
job.postpone(result=message, seconds=PG_RETRY)
78+
job.set_pending(reset_retry=False)
79+
job.store()
80+
_logger.debug("%s OperationalError, postponed", job)
81+
82+
except NothingToDoJob as err:
83+
if str(err):
84+
msg = str(err)
85+
else:
86+
msg = _("Job interrupted and set to Done: nothing to do.")
87+
job.set_done(msg)
88+
job.store()
89+
90+
except RetryableJobError as err:
91+
# delay the job later, requeue
92+
job.postpone(result=str(err), seconds=5)
93+
job.set_pending(reset_retry=False)
94+
job.store()
95+
_logger.debug("%s postponed", job)
96+
97+
except (FailedJobError, Exception):
98+
with StringIO() as buff:
99+
traceback.print_exc(file=buff)
100+
_logger.error(buff.getvalue())
101+
job.set_failed(exc_info=buff.getvalue())
102+
job.store()
103+
104+
if commit: # pragma: no cover
105+
self.env["base"].flush()
106+
self.env.cr.commit() # pylint: disable=invalid-commit
107+
108+
@api.model
109+
def _job_runner(self, commit=True):
110+
"""Short-lived job runner, triggered by async crons"""
111+
job = self._acquire_one_job()
112+
while job:
113+
job._process(commit=commit)
114+
job = self._acquire_one_job()
115+
# TODO: If limit_time_real_cron is reached before all the jobs are done,
116+
# the worker will be killed abruptly.
117+
# Ideally, find a way to know if we're close to reaching this limit,
118+
# stop processing, and trigger a new execution to continue.
119+
#
120+
# if job and limit_time_real_cron_reached_or_about_to_reach:
121+
# self._cron_trigger()
122+
# break
123+
124+
@api.model
125+
def _cron_trigger(self, at=None):
126+
"""Trigger the cron job runners
127+
128+
Odoo will prevent concurrent cron jobs from running.
129+
So, to support parallel execution, we'd need to have (at least) the
130+
same number of ir.crons records as cron workers.
131+
132+
All crons should be triggered at the same time.
133+
"""
134+
crons = self.env["ir.cron"].sudo().search([("queue_job_runner", "=", True)])
135+
for cron in crons:
136+
cron._trigger(at=at)
137+
138+
def _ensure_cron_trigger(self):
139+
"""Create cron triggers for these jobs"""
140+
records = self.filtered(lambda r: r.state == "pending")
141+
if not records:
142+
return
143+
# Trigger immediate runs
144+
immediate = any(not rec.eta for rec in records)
145+
if immediate:
146+
self._cron_trigger()
147+
# Trigger delayed eta runs
148+
delayed_etas = {rec.eta for rec in records if rec.eta}
149+
if delayed_etas:
150+
self._cron_trigger(at=list(delayed_etas))
151+
152+
@api.model_create_multi
153+
def create(self, vals_list):
154+
# When jobs are created, also create the cron trigger
155+
records = super().create(vals_list)
156+
records._ensure_cron_trigger()
157+
return records
158+
159+
def write(self, vals):
160+
# When a job state or eta changes, make sure a cron trigger is created
161+
res = super().write(vals)
162+
if "state" in vals or "eta" in vals:
163+
self._ensure_cron_trigger()
164+
return res
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.. warning::
2+
3+
Don't use this module if you're already running the regular ``queue_job`` runner.
4+
5+
6+
For the easiest case, no configuration is required besides installing the module.
7+
8+
To avoid CronWorker CPU timeout from abruptly stopping the job processing cron, it's
9+
recommended to launch Odoo with ``--limit-time-real-cron=0``, to disable the CronWorker
10+
timeout altogether.
11+
12+
.. note::
13+
14+
In Odoo.sh, this is done by default.
15+
16+
17+
Parallel execution of jobs can be achieved by leveraging multiple ``ir.cron`` records:
18+
19+
* Make sure you have enough CronWorkers available (Odoo CLI ``--max-cron-threads``)
20+
* Duplicate the ``queue_job_cron`` cron record as many times as needed, until you have
21+
as much records as cron workers.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
* `Camptocamp <https://www.camptocamp.com>`_
2+
3+
* Iván Todorovich <[email protected]>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
This module implements a simple ``queue.job`` runner using ``ir.cron`` triggers.
2+
3+
It's meant to be used on environments where the regular job runner can't be run, like
4+
on Odoo.sh.
5+
6+
Unlike the regular job runner, where jobs are dispatched to the HttpWorkers, jobs are
7+
processed on the CronWorker threads by the job runner crons. This is a design decision
8+
because:
9+
10+
* Odoo.sh puts HttpWorkers to sleep when there's no network activity
11+
* HttpWorkers are meant for traffic. Users shouldn't pay the price of background tasks.
12+
13+
For now, it only implements the most basic features of the ``queue_job`` runner, notably
14+
no channel capacity nor priorities. Please check the ROADMAP for further details.

0 commit comments

Comments
 (0)