From 613d9960862bcb767d38872b826362ec99d46f8d Mon Sep 17 00:00:00 2001 From: RLeeOSI <51208020+RLeeOSI@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:52:40 -0800 Subject: [PATCH 01/16] [REF] queue_job: documentation cleanup --- queue_job/jobrunner/runner.py | 106 +--------------------------------- queue_job/readme/CONFIGURE.md | 16 ++++- 2 files changed, 14 insertions(+), 108 deletions(-) diff --git a/queue_job/jobrunner/runner.py b/queue_job/jobrunner/runner.py index 681d03fadf..7fd91d68ba 100644 --- a/queue_job/jobrunner/runner.py +++ b/queue_job/jobrunner/runner.py @@ -16,111 +16,7 @@ * It maintains an in-memory priority queue of jobs that is populated from the queue_job tables in all databases. * It does not run jobs itself, but asks Odoo to run them through an - anonymous ``/queue_job/runjob`` HTTP request. [1]_ - -How to use it? --------------- - -* Optionally adjust your configuration through environment variables: - - - ``ODOO_QUEUE_JOB_CHANNELS=root:4`` (or any other channels - configuration), default ``root:1``. - - ``ODOO_QUEUE_JOB_SCHEME=https``, default ``http``. - - ``ODOO_QUEUE_JOB_HOST=load-balancer``, default ``http_interface`` - or ``localhost`` if unset. - - ``ODOO_QUEUE_JOB_PORT=443``, default ``http_port`` or 8069 if unset. - - ``ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner``, default empty. - - ``ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t``, default empty. - - ``ODOO_QUEUE_JOB_JOBRUNNER_DB_HOST=master-db``, default ``db_host`` - or ``False`` if unset. - - ``ODOO_QUEUE_JOB_JOBRUNNER_DB_PORT=5432``, default ``db_port`` - or ``False`` if unset. - - ``ODOO_QUEUE_JOB_JOBRUNNER_DB_USER=userdb``, default ``db_user`` - or ``False`` if unset. - - ``ODOO_QUEUE_JOB_JOBRUNNER_DB_PASSWORD=passdb``, default ``db_password`` - or ``False`` if unset. - -* Alternatively, configure the channels through the Odoo configuration - file, like: - -.. code-block:: ini - - [queue_job] - channels = root:4 - scheme = https - host = load-balancer - port = 443 - http_auth_user = jobrunner - http_auth_password = s3cr3t - jobrunner_db_host = master-db - jobrunner_db_port = 5432 - jobrunner_db_user = userdb - jobrunner_db_password = passdb - -* Or, if using ``anybox.recipe.odoo``, add this to your buildout configuration: - -.. code-block:: ini - - [odoo] - recipe = anybox.recipe.odoo - (...) - queue_job.channels = root:4 - queue_job.scheme = https - queue_job.host = load-balancer - queue_job.port = 443 - queue_job.http_auth_user = jobrunner - queue_job.http_auth_password = s3cr3t - -* Start Odoo with ``--load=web,web_kanban,queue_job`` - and ``--workers`` greater than 1 [2]_, or set the ``server_wide_modules`` - option in The Odoo configuration file: - -.. code-block:: ini - - [options] - (...) - workers = 4 - server_wide_modules = web,web_kanban,queue_job - (...) - -* Or, if using ``anybox.recipe.odoo``: - -.. code-block:: ini - - [odoo] - recipe = anybox.recipe.odoo - (...) - options.workers = 4 - options.server_wide_modules = web,web_kanban,queue_job - -* Confirm the runner is starting correctly by checking the odoo log file: - -.. code-block:: none - - ...INFO...queue_job.jobrunner.runner: starting - ...INFO...queue_job.jobrunner.runner: initializing database connections - ...INFO...queue_job.jobrunner.runner: queue job runner ready for db - ...INFO...queue_job.jobrunner.runner: database connections ready - -* Create jobs (eg using base_import_async) and observe they - start immediately and in parallel. - -* Tip: to enable debug logging for the queue job, use - ``--log-handler=odoo.addons.queue_job:DEBUG`` - -Caveat ------- - -* After creating a new database or installing queue_job on an - existing database, Odoo must be restarted for the runner to detect it. - -.. rubric:: Footnotes - -.. [1] From a security standpoint, it is safe to have an anonymous HTTP - request because this request only accepts to run jobs that are - enqueued. -.. [2] It works with the threaded Odoo server too, although this way - of running Odoo is obviously not for production purposes. + anonymous ``/queue_job/runjob`` HTTP request. """ import logging diff --git a/queue_job/readme/CONFIGURE.md b/queue_job/readme/CONFIGURE.md index 216b5358af..7239106218 100644 --- a/queue_job/readme/CONFIGURE.md +++ b/queue_job/readme/CONFIGURE.md @@ -2,9 +2,14 @@ - Adjust environment variables (optional): - `ODOO_QUEUE_JOB_CHANNELS=root:4` or any other channels configuration. The default is `root:1` - - if `xmlrpc_port` is not set: `ODOO_QUEUE_JOB_PORT=8069` - - Start Odoo with `--load=web,queue_job` and `--workers` greater than - 1.[^1] + - `ODOO_QUEUE_JOB_PORT=8069`, default `--http-port` + - `ODOO_QUEUE_JOB_SCHEME=https`, default `http` + - `ODOO_QUEUE_JOB_HOST=load-balancer`, default `--http-interface` + or `localhost` if unset + - `ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner`, default empty + - `ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t`, default empty + - Start Odoo with `--load=web,queue_job` and `--workers` greater than + 1.[^1] - Using the Odoo configuration file: ``` ini @@ -16,6 +21,11 @@ server_wide_modules = web,queue_job (...) [queue_job] channels = root:2 +scheme = https +host = load-balancer +port = 443 +http_auth_user = jobrunner +http_auth_password = s3cr3t ``` - Confirm the runner is starting correctly by checking the odoo log From 5d93d393b74a3879aeac8c63a9bfcc548a7c264f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 19 Feb 2026 16:31:17 +0100 Subject: [PATCH 02/16] Skip check of dependencies when a done job has no dependents Every time a job is done, even if it is not part of a graph, it runs a query to look for dependents to enqueue. Storing the dependent uuids in the "dependencies" field was on purpose to know that we have no further jobs in the graph and that we can skip the check entirely and have no overhead in this case. It looks like an oversight, we can add the missing condition. --- queue_job/controllers/main.py | 7 +++++-- queue_job/job.py | 3 +++ test_queue_job/tests/test_dependencies.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index c867711408..fd3198cc80 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -108,6 +108,10 @@ def _try_perform_job(cls, env, job): @classmethod def _enqueue_dependent_jobs(cls, env, job): + if not job.should_check_dependents(): + return + + _logger.debug("%s enqueue depends started", job) tries = 0 while True: try: @@ -136,6 +140,7 @@ def _enqueue_dependent_jobs(cls, env, job): time.sleep(wait_time) else: break + _logger.debug("%s enqueue depends done", job) @classmethod def _runjob(cls, env: api.Environment, job: Job) -> None: @@ -182,9 +187,7 @@ def retry_postpone(job, message, seconds=None): buff.close() raise - _logger.debug("%s enqueue depends started", job) cls._enqueue_dependent_jobs(env, job) - _logger.debug("%s enqueue depends done", job) @classmethod def _get_failure_values(cls, job, traceback_txt, orig_exception): diff --git a/queue_job/job.py b/queue_job/job.py index 3eca2d2661..70b2e237a7 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -538,6 +538,9 @@ def _get_common_dependent_jobs_query(self): AND state = %s; """ + def should_check_dependents(self): + return any(self.__reverse_depends_on_uuids) + def enqueue_waiting(self): sql = self._get_common_dependent_jobs_query() self.env.cr.execute(sql, (PENDING, self.uuid, DONE, WAIT_DEPENDENCIES)) diff --git a/test_queue_job/tests/test_dependencies.py b/test_queue_job/tests/test_dependencies.py index 4246fdbeba..d8a9253f00 100644 --- a/test_queue_job/tests/test_dependencies.py +++ b/test_queue_job/tests/test_dependencies.py @@ -287,3 +287,16 @@ def test_depends_graph_uuid_group(self): self.assertTrue(jobs[0].graph_uuid) self.assertTrue(jobs[1].graph_uuid) self.assertEqual(jobs[0].graph_uuid, jobs[1].graph_uuid) + + def test_should_check_dependents(self): + job_root = Job(self.method) + job_a = Job(self.method) + job_a.add_depends({job_root}) + + DelayableGraph._ensure_same_graph_uuid([job_root, job_a]) + + job_root.store() + job_a.store() + + self.assertTrue(job_root.should_check_dependents()) + self.assertFalse(job_a.should_check_dependents()) From b7bda7f741bb32a3121535926853cd0a4f2870e3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 19 Feb 2026 17:02:42 +0100 Subject: [PATCH 03/16] Fix dependents error after retryable job error --- queue_job/controllers/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index fd3198cc80..a1408799a3 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -172,6 +172,7 @@ def retry_postpone(job, message, seconds=None): # traceback in the logs we should have the traceback when all # retries are exhausted env.cr.rollback() + return except (FailedJobError, Exception) as orig_exception: buff = StringIO() From d418918bb0a43233d4caf6f8473563dae38b2762 Mon Sep 17 00:00:00 2001 From: Andrii9090-tecnativa Date: Wed, 4 Mar 2026 11:45:50 +0100 Subject: [PATCH 04/16] [FIX] queue_job: Fix TestJson In this case, when a module adds a value in context, the tests fail --- queue_job/tests/test_json_field.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/queue_job/tests/test_json_field.py b/queue_job/tests/test_json_field.py index 76bb59c977..23974e23c9 100644 --- a/queue_job/tests/test_json_field.py +++ b/queue_job/tests/test_json_field.py @@ -45,9 +45,13 @@ def test_encoder_recordset(self): "model": "res.partner", "ids": [partner.id], "su": False, - "context": expected_context, } - self.assertEqual(json.loads(value_json), expected) + result_dict = json.loads(value_json) + result_context = result_dict.pop("context") + self.assertEqual(result_dict, expected) + # context is tested separately as the order/amount of keys is not guaranteed + for key in result_context: + self.assertEqual(result_context[key], expected_context[key]) def test_encoder_recordset_list(self): demo_user = self.demo_user @@ -69,7 +73,20 @@ def test_encoder_recordset_list(self): "context": expected_context, }, ] - self.assertEqual(json.loads(value_json), expected) + result_dict = json.loads(value_json) + for result_value, expected_value in zip(result_dict, expected, strict=False): + if isinstance(expected_value, dict): + for key in result_value: + if key == "context": + for context_key in result_value["context"]: + self.assertEqual( + result_value["context"][context_key], + expected_value["context"][context_key], + ) + else: + self.assertEqual(result_value[key], expected_value[key]) + else: + self.assertEqual(result_value, expected_value) def test_decoder_recordset(self): demo_user = self.demo_user From a87d0750a40eac06fa53573fb5a917fd6d713e8f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 19 Feb 2026 15:55:48 +0100 Subject: [PATCH 05/16] Add 'Allow Commit' option on job functions It is forbidden to commit inside a job, because it releases the job lock and can cause it to start again, while still being run, by the dead jobs requeuer. For some use cases, it may actually be legitimate, or at least be needed in the short term before actual updates in the code. A new option on the job function, false by default, allow to run the job in a new transaction, at the cost of an additional connection + transaction overhead. Related to #889 --- queue_job/controllers/main.py | 5 ++-- queue_job/job.py | 26 ++++++++++++++++---- queue_job/models/queue_job_function.py | 10 +++++++- queue_job/tests/common.py | 2 +- queue_job/tests/test_model_job_function.py | 2 ++ queue_job/views/queue_job_function_views.xml | 2 ++ 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index a1408799a3..00efc12593 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -38,8 +38,9 @@ def _prevent_commit(cr): def forbidden_commit(*args, **kwargs): raise RuntimeError( "Commit is forbidden in queue jobs. " - "If the current job is a cron running as queue job, " - "modify it to run as a normal cron." + 'You may want to enable the "Allow Commit" option on the Job ' + "Function. Alternatively, if the current job is a cron running as " + "queue job, you can modify it to run as a normal cron." ) original_commit = cr.commit diff --git a/queue_job/job.py b/queue_job/job.py index 70b2e237a7..6bb057e625 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -8,6 +8,7 @@ import sys import uuid import weakref +from contextlib import contextmanager, nullcontext from datetime import datetime, timedelta from random import randint @@ -407,10 +408,6 @@ def __init__( self.method_name = func.__name__ self.recordset = recordset - self.env = env - self.job_model = self.env["queue.job"] - self.job_model_name = "queue.job" - self.job_config = ( self.env["queue.job.function"].sudo().job_config(self.job_function_name) ) @@ -488,7 +485,12 @@ def perform(self): """ self.retry += 1 try: - self.result = self.func(*tuple(self.args), **self.kwargs) + if self.job_config.allow_commit: + env_context_manager = self._with_temporary_env() + else: + env_context_manager = nullcontext() + with env_context_manager: + self.result = self.func(*tuple(self.args), **self.kwargs) except RetryableJobError as err: if err.ignore_retry: self.retry -= 1 @@ -508,6 +510,16 @@ def perform(self): return self.result + @contextmanager + def _with_temporary_env(self): + with self.env.registry.cursor() as new_cr: + env = self.recordset.env + self.recordset = self.recordset.with_env(env(cr=new_cr)) + try: + yield + finally: + self.recordset = self.recordset.with_env(env) + def _get_common_dependent_jobs_query(self): return """ UPDATE queue_job @@ -669,6 +681,10 @@ def __hash__(self): def db_record(self): return self.db_records_from_uuids(self.env, [self.uuid]) + @property + def env(self): + return self.recordset.env + @property def func(self): recordset = self.recordset.with_context(job_uuid=self.uuid) diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index 5f86f7a214..1d2cb7bc19 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -28,7 +28,8 @@ class QueueJobFunction(models.Model): "related_action_enable " "related_action_func_name " "related_action_kwargs " - "job_function_id ", + "job_function_id " + "allow_commit", ) def _default_channel(self): @@ -79,6 +80,11 @@ def _default_channel(self): "enable, func_name, kwargs.\n" "See the module description for details.", ) + allow_commit = fields.Boolean( + help="Allows the job to commit transactions during execution. " + "Under the hood, this executes the job in a new database cursor, " + "which incurs a slight overhead.", + ) @api.depends("model_id.model", "method") def _compute_name(self): @@ -151,6 +157,7 @@ def job_default_config(self): related_action_func_name=None, related_action_kwargs={}, job_function_id=None, + allow_commit=False, ) def _parse_retry_pattern(self): @@ -186,6 +193,7 @@ def job_config(self, name): related_action_func_name=config.related_action.get("func_name"), related_action_kwargs=config.related_action.get("kwargs", {}), job_function_id=config.id, + allow_commit=config.allow_commit, ) def _retry_pattern_format_error_message(self): diff --git a/queue_job/tests/common.py b/queue_job/tests/common.py index 318f437098..f74e4ad651 100644 --- a/queue_job/tests/common.py +++ b/queue_job/tests/common.py @@ -274,7 +274,7 @@ def _add_job(self, *args, **kwargs): def _prepare_context(self, job): # pylint: disable=context-overridden - job_model = job.job_model.with_context({}) + job_model = job.env["queue.job"].with_context({}) field_records = job_model._fields["records"] # Filter the context to simulate store/load of the job job.recordset = field_records.convert_to_write(job.recordset, job_model) diff --git a/queue_job/tests/test_model_job_function.py b/queue_job/tests/test_model_job_function.py index 84676fdb65..9095f2a55e 100644 --- a/queue_job/tests/test_model_job_function.py +++ b/queue_job/tests/test_model_job_function.py @@ -42,6 +42,7 @@ def test_function_job_config(self): ' "func_name": "related_action_foo",' ' "kwargs": {"b": 1}}' ), + "allow_commit": True, } ) self.assertEqual( @@ -53,5 +54,6 @@ def test_function_job_config(self): related_action_func_name="related_action_foo", related_action_kwargs={"b": 1}, job_function_id=job_function.id, + allow_commit=True, ), ) diff --git a/queue_job/views/queue_job_function_views.xml b/queue_job/views/queue_job_function_views.xml index 96f33bb09e..ca481f5777 100644 --- a/queue_job/views/queue_job_function_views.xml +++ b/queue_job/views/queue_job_function_views.xml @@ -10,6 +10,7 @@ + @@ -24,6 +25,7 @@ + From 5fd0e382830c5676f0ca9262948d23caca32f56c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 19 Feb 2026 17:31:37 +0100 Subject: [PATCH 06/16] Add parameter to allow commit by default in jobs False on new databases, True on existing databases. Should always be False by default on future versions. --- queue_job/__manifest__.py | 1 + queue_job/data/ir_config_parameter_data.xml | 7 +++++++ .../migrations/18.0.2.2.0/post-migration.py | 13 +++++++++++++ queue_job/models/queue_job_function.py | 16 +++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 queue_job/data/ir_config_parameter_data.xml create mode 100644 queue_job/migrations/18.0.2.2.0/post-migration.py diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index 44d4d63d7a..5dd8553e2b 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -21,6 +21,7 @@ "views/queue_job_menus.xml", "data/queue_data.xml", "data/queue_job_function_data.xml", + "data/ir_config_parameter_data.xml", ], "assets": { "web.assets_backend": [ diff --git a/queue_job/data/ir_config_parameter_data.xml b/queue_job/data/ir_config_parameter_data.xml new file mode 100644 index 0000000000..1cfdcd19bc --- /dev/null +++ b/queue_job/data/ir_config_parameter_data.xml @@ -0,0 +1,7 @@ + + + + queue_job.allow_commit_by_default + False + + diff --git a/queue_job/migrations/18.0.2.2.0/post-migration.py b/queue_job/migrations/18.0.2.2.0/post-migration.py new file mode 100644 index 0000000000..216fe49a8d --- /dev/null +++ b/queue_job/migrations/18.0.2.2.0/post-migration.py @@ -0,0 +1,13 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + if not version: + return + + env["ir.config_parameter"].sudo().set_param( + "queue_job.allow_commit_by_default", True + ) + env["queue.job.function"].search([]).write({"allow_commit": True}) diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index 1d2cb7bc19..442bc99068 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -7,6 +7,7 @@ from collections import namedtuple from odoo import api, exceptions, fields, models, tools +from odoo.tools import str2bool from ..fields import JobSerialized @@ -81,6 +82,7 @@ def _default_channel(self): "See the module description for details.", ) allow_commit = fields.Boolean( + default=lambda self: self._default_allow_commit_by_default(), help="Allows the job to commit transactions during execution. " "Under the hood, this executes the job in a new database cursor, " "which incurs a slight overhead.", @@ -157,7 +159,19 @@ def job_default_config(self): related_action_func_name=None, related_action_kwargs={}, job_function_id=None, - allow_commit=False, + allow_commit=self._default_allow_commit_by_default(), + ) + + @api.model + def _default_allow_commit_by_default(self): + # We shoud not allow commit by default on job functions, this parameter + # is here for backward compatibility, a migration sets it by default on + # existing databases, but new databases will have it set to False by + # default. + return str2bool( + self.env["ir.config_parameter"] + .sudo() + .get_param("queue_job.allow_commit_by_default") ) def _parse_retry_pattern(self): From 12b07f1d74f11f6975eb75b425fd6318ec15dbae Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Feb 2026 15:15:18 +0100 Subject: [PATCH 07/16] Revert "Add parameter to allow commit by default in jobs" This reverts commit b4f3bec9ba7516635c3e8992da724499f708285e. --- queue_job/__manifest__.py | 1 - queue_job/data/ir_config_parameter_data.xml | 7 ------- .../migrations/18.0.2.2.0/post-migration.py | 13 ------------- queue_job/models/queue_job_function.py | 16 +--------------- 4 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 queue_job/data/ir_config_parameter_data.xml delete mode 100644 queue_job/migrations/18.0.2.2.0/post-migration.py diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index 5dd8553e2b..44d4d63d7a 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -21,7 +21,6 @@ "views/queue_job_menus.xml", "data/queue_data.xml", "data/queue_job_function_data.xml", - "data/ir_config_parameter_data.xml", ], "assets": { "web.assets_backend": [ diff --git a/queue_job/data/ir_config_parameter_data.xml b/queue_job/data/ir_config_parameter_data.xml deleted file mode 100644 index 1cfdcd19bc..0000000000 --- a/queue_job/data/ir_config_parameter_data.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - queue_job.allow_commit_by_default - False - - diff --git a/queue_job/migrations/18.0.2.2.0/post-migration.py b/queue_job/migrations/18.0.2.2.0/post-migration.py deleted file mode 100644 index 216fe49a8d..0000000000 --- a/queue_job/migrations/18.0.2.2.0/post-migration.py +++ /dev/null @@ -1,13 +0,0 @@ -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) -from openupgradelib import openupgrade - - -@openupgrade.migrate() -def migrate(env, version): - if not version: - return - - env["ir.config_parameter"].sudo().set_param( - "queue_job.allow_commit_by_default", True - ) - env["queue.job.function"].search([]).write({"allow_commit": True}) diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index 442bc99068..1d2cb7bc19 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -7,7 +7,6 @@ from collections import namedtuple from odoo import api, exceptions, fields, models, tools -from odoo.tools import str2bool from ..fields import JobSerialized @@ -82,7 +81,6 @@ def _default_channel(self): "See the module description for details.", ) allow_commit = fields.Boolean( - default=lambda self: self._default_allow_commit_by_default(), help="Allows the job to commit transactions during execution. " "Under the hood, this executes the job in a new database cursor, " "which incurs a slight overhead.", @@ -159,19 +157,7 @@ def job_default_config(self): related_action_func_name=None, related_action_kwargs={}, job_function_id=None, - allow_commit=self._default_allow_commit_by_default(), - ) - - @api.model - def _default_allow_commit_by_default(self): - # We shoud not allow commit by default on job functions, this parameter - # is here for backward compatibility, a migration sets it by default on - # existing databases, but new databases will have it set to False by - # default. - return str2bool( - self.env["ir.config_parameter"] - .sudo() - .get_param("queue_job.allow_commit_by_default") + allow_commit=False, ) def _parse_retry_pattern(self): From dc539f2c9056841910d42bd6638b59e585c3a157 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Feb 2026 15:16:52 +0100 Subject: [PATCH 08/16] Improve documentation on allow commit --- queue_job/controllers/main.py | 3 ++- queue_job/models/queue_job_function.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index 00efc12593..8b8458d2d8 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -40,7 +40,8 @@ def forbidden_commit(*args, **kwargs): "Commit is forbidden in queue jobs. " 'You may want to enable the "Allow Commit" option on the Job ' "Function. Alternatively, if the current job is a cron running as " - "queue job, you can modify it to run as a normal cron." + "queue job, you can modify it to run as a normal cron. More details on: " + "https://github.com/OCA/queue/wiki/%5BDRAFT%5D-Upgrade-warning:-commits-inside-jobs" ) original_commit = cr.commit diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index 1d2cb7bc19..60061b1e3b 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -83,7 +83,8 @@ def _default_channel(self): allow_commit = fields.Boolean( help="Allows the job to commit transactions during execution. " "Under the hood, this executes the job in a new database cursor, " - "which incurs a slight overhead.", + "which incurs an overhead as it requires an extra connection to " + "the database. " ) @api.depends("model_id.model", "method") From b2dff6375b9eb12b50ff5fb285e192df3945e865 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Feb 2026 11:35:38 +0100 Subject: [PATCH 09/16] Fix missing job.env setter As the controller changes env on Job instances. --- queue_job/controllers/main.py | 4 +++- queue_job/job.py | 11 +++++++---- queue_job/tests/test_run_rob_controller.py | 6 ++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index 8b8458d2d8..cee3cd97dc 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -15,6 +15,7 @@ from odoo import SUPERUSER_ID, api, http from odoo.modules.registry import Registry from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY +from odoo.tools import config from ..delay import chain, group from ..exception import FailedJobError, RetryableJobError @@ -105,7 +106,8 @@ def _try_perform_job(cls, env, job): job.set_done() job.store() env.flush_all() - env.cr.commit() + if not config["test_enable"]: + env.cr.commit() _logger.debug("%s done", job) @classmethod diff --git a/queue_job/job.py b/queue_job/job.py index 6bb057e625..a39c341b75 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -404,7 +404,6 @@ def __init__( raise TypeError("Job accepts only methods of Models") recordset = func.__self__ - env = recordset.env self.method_name = func.__name__ self.recordset = recordset @@ -457,10 +456,10 @@ def __init__( self.exc_message = None self.exc_info = None - if "company_id" in env.context: - company_id = env.context["company_id"] + if "company_id" in self.env.context: + company_id = self.env.context["company_id"] else: - company_id = env.company.id + company_id = self.env.company.id self.company_id = company_id self._eta = None self.eta = eta @@ -685,6 +684,10 @@ def db_record(self): def env(self): return self.recordset.env + @env.setter + def env(self, env): + self.recordset = self.recordset.with_env(env) + @property def func(self): recordset = self.recordset.with_context(job_uuid=self.uuid) diff --git a/queue_job/tests/test_run_rob_controller.py b/queue_job/tests/test_run_rob_controller.py index bb63bc82ec..1a15f4363a 100644 --- a/queue_job/tests/test_run_rob_controller.py +++ b/queue_job/tests/test_run_rob_controller.py @@ -15,3 +15,9 @@ def test_get_failure_values(self): self.assertEqual( rslt, {"exc_info": "info", "exc_name": "Exception", "exc_message": "zero"} ) + + def test_runjob_success(self): + job = self.env["queue.job"].with_delay()._test_job() + RunJobController._runjob(self.env, job) + self.assertEqual(job.state, "done") + self.assertEqual(job.db_record().state, "done") From efa83b8049bc425de173884db8e026f22557a631 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Feb 2026 16:00:20 +0100 Subject: [PATCH 10/16] Add failure retry in queue job test job --- queue_job/controllers/main.py | 13 +++++++++++++ queue_job/models/queue_job.py | 19 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index cee3cd97dc..069fa563aa 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -237,6 +237,7 @@ def create_test_job( failure_rate=0, job_duration=0, commit_within_job=False, + failure_retry_seconds=0, ): if not http.request.env.user.has_group("base.group_erp_manager"): raise Forbidden(http.request.env._("Access Denied")) @@ -274,6 +275,12 @@ def create_test_job( except ValueError: max_retries = None + if failure_retry_seconds is not None: + try: + failure_retry_seconds = int(failure_retry_seconds) + except ValueError: + failure_retry_seconds = 0 + if size == 1: return self._create_single_test_job( priority=priority, @@ -283,6 +290,7 @@ def create_test_job( failure_rate=failure_rate, job_duration=job_duration, commit_within_job=commit_within_job, + failure_retry_seconds=failure_retry_seconds, ) if size > 1: @@ -295,6 +303,7 @@ def create_test_job( failure_rate=failure_rate, job_duration=job_duration, commit_within_job=commit_within_job, + failure_retry_seconds=failure_retry_seconds, ) return "" @@ -308,6 +317,7 @@ def _create_single_test_job( failure_rate=0, job_duration=0, commit_within_job=False, + failure_retry_seconds=0, ): delayed = ( http.request.env["queue.job"] @@ -321,6 +331,7 @@ def _create_single_test_job( failure_rate=failure_rate, job_duration=job_duration, commit_within_job=commit_within_job, + failure_retry_seconds=failure_retry_seconds, ) ) return f"job uuid: {delayed.db_record().uuid}" @@ -337,6 +348,7 @@ def _create_graph_test_jobs( failure_rate=0, job_duration=0, commit_within_job=False, + failure_retry_seconds=0, ): model = http.request.env["queue.job"] current_count = 0 @@ -363,6 +375,7 @@ def _create_graph_test_jobs( failure_rate=failure_rate, job_duration=job_duration, commit_within_job=commit_within_job, + failure_retry_seconds=failure_retry_seconds, ) ) diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 5f00acab57..cab262504c 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -13,7 +13,7 @@ from odoo.addons.base_sparse_field.models.fields import Serialized from ..delay import Graph -from ..exception import JobError +from ..exception import JobError, RetryableJobError from ..fields import JobSerialized from ..job import ( CANCELLED, @@ -458,10 +458,23 @@ def related_action_open_record(self): ) return action - def _test_job(self, failure_rate=0, job_duration=0, commit_within_job=False): + def _test_job( + self, + failure_rate=0, + job_duration=0, + commit_within_job=False, + failure_retry_seconds=0, + ): _logger.info("Running test job.") if random.random() <= failure_rate: - raise JobError("Job failed") + if failure_retry_seconds: + raise RetryableJobError( + f"Retryable job failed, will be retried in " + f"{failure_retry_seconds} seconds", + seconds=failure_retry_seconds, + ) + else: + raise JobError("Job failed") if job_duration: time.sleep(job_duration) if commit_within_job: From 6af167170bf5be4798f1d55ed58d2b78ebaa60e1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Feb 2026 16:00:50 +0100 Subject: [PATCH 11/16] Simplify job env management --- queue_job/controllers/main.py | 12 ++++-------- queue_job/job.py | 14 +++++++------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index 069fa563aa..0ea8cef2ee 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -9,13 +9,11 @@ from contextlib import contextmanager from io import StringIO -from psycopg2 import OperationalError, errorcodes -from werkzeug.exceptions import BadRequest, Forbidden - from odoo import SUPERUSER_ID, api, http -from odoo.modules.registry import Registry from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY from odoo.tools import config +from psycopg2 import OperationalError, errorcodes +from werkzeug.exceptions import BadRequest, Forbidden from ..delay import chain, group from ..exception import FailedJobError, RetryableJobError @@ -150,8 +148,7 @@ def _enqueue_dependent_jobs(cls, env, job): def _runjob(cls, env: api.Environment, job: Job) -> None: def retry_postpone(job, message, seconds=None): job.env.clear() - with Registry(job.env.cr.dbname).cursor() as new_cr: - job.env = api.Environment(new_cr, SUPERUSER_ID, {}) + with job.in_temporary_env(): job.postpone(result=message, seconds=seconds) job.set_pending(reset_retry=False) job.store() @@ -184,8 +181,7 @@ def retry_postpone(job, message, seconds=None): traceback_txt = buff.getvalue() _logger.error(traceback_txt) job.env.clear() - with Registry(job.env.cr.dbname).cursor() as new_cr: - job.env = job.env(cr=new_cr) + with job.in_temporary_env(): vals = cls._get_failure_values(job, traceback_txt, orig_exception) job.set_failed(**vals) job.store() diff --git a/queue_job/job.py b/queue_job/job.py index a39c341b75..86314499bd 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -485,7 +485,7 @@ def perform(self): self.retry += 1 try: if self.job_config.allow_commit: - env_context_manager = self._with_temporary_env() + env_context_manager = self.in_temporary_env() else: env_context_manager = nullcontext() with env_context_manager: @@ -510,14 +510,14 @@ def perform(self): return self.result @contextmanager - def _with_temporary_env(self): + def in_temporary_env(self): with self.env.registry.cursor() as new_cr: - env = self.recordset.env - self.recordset = self.recordset.with_env(env(cr=new_cr)) + env = self.env + self._env = env(cr=new_cr) try: yield finally: - self.recordset = self.recordset.with_env(env) + self._env = env def _get_common_dependent_jobs_query(self): return """ @@ -685,7 +685,7 @@ def env(self): return self.recordset.env @env.setter - def env(self, env): + def _env(self, env): self.recordset = self.recordset.with_env(env) @property @@ -752,7 +752,7 @@ def model_name(self): @property def user_id(self): - return self.recordset.env.uid + return self.env.uid @property def eta(self): From 4fdd8232065fb51ee7dec07cf44789eec08a618a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Feb 2026 08:56:23 +0100 Subject: [PATCH 12/16] Update upgrade warning link --- queue_job/controllers/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index 0ea8cef2ee..df8548eebf 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -40,7 +40,7 @@ def forbidden_commit(*args, **kwargs): 'You may want to enable the "Allow Commit" option on the Job ' "Function. Alternatively, if the current job is a cron running as " "queue job, you can modify it to run as a normal cron. More details on: " - "https://github.com/OCA/queue/wiki/%5BDRAFT%5D-Upgrade-warning:-commits-inside-jobs" + "https://github.com/OCA/queue/wiki/Upgrade-warning:-commits-inside-jobs" ) original_commit = cr.commit From ffc5b6f76f9c09828a690d71e39efe80b0f2b14c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 23 Mar 2026 13:35:24 +0100 Subject: [PATCH 13/16] pre-commit run -a --- queue_job/README.rst | 17 +++++++++++++---- queue_job/controllers/main.py | 5 +++-- queue_job/static/description/index.html | 18 ++++++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/queue_job/README.rst b/queue_job/README.rst index f401953e0c..c056a4e08b 100644 --- a/queue_job/README.rst +++ b/queue_job/README.rst @@ -99,10 +99,14 @@ Configuration - ``ODOO_QUEUE_JOB_CHANNELS=root:4`` or any other channels configuration. The default is ``root:1`` - - if ``xmlrpc_port`` is not set: ``ODOO_QUEUE_JOB_PORT=8069`` - - - Start Odoo with ``--load=web,queue_job`` and ``--workers`` greater - than 1. [1]_ + - ``ODOO_QUEUE_JOB_PORT=8069``, default ``--http-port`` + - ``ODOO_QUEUE_JOB_SCHEME=https``, default ``http`` + - ``ODOO_QUEUE_JOB_HOST=load-balancer``, default + ``--http-interface`` or ``localhost`` if unset + - ``ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner``, default empty + - ``ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t``, default empty + - Start Odoo with ``--load=web,queue_job`` and ``--workers`` greater + than 1. [1]_ - Using the Odoo configuration file: @@ -116,6 +120,11 @@ Configuration (...) [queue_job] channels = root:2 + scheme = https + host = load-balancer + port = 443 + http_auth_user = jobrunner + http_auth_password = s3cr3t - Confirm the runner is starting correctly by checking the odoo log file: diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index df8548eebf..a1729b1fe8 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -9,11 +9,12 @@ from contextlib import contextmanager from io import StringIO +from psycopg2 import OperationalError, errorcodes +from werkzeug.exceptions import BadRequest, Forbidden + from odoo import SUPERUSER_ID, api, http from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY from odoo.tools import config -from psycopg2 import OperationalError, errorcodes -from werkzeug.exceptions import BadRequest, Forbidden from ..delay import chain, group from ..exception import FailedJobError, RetryableJobError diff --git a/queue_job/static/description/index.html b/queue_job/static/description/index.html index c54c9d9c00..7dcf28a60d 100644 --- a/queue_job/static/description/index.html +++ b/queue_job/static/description/index.html @@ -460,13 +460,18 @@

Configuration

  • Adjust environment variables (optional):
    • ODOO_QUEUE_JOB_CHANNELS=root:4 or any other channels configuration. The default is root:1
    • -
    • if xmlrpc_port is not set: ODOO_QUEUE_JOB_PORT=8069
    • -
    -
  • +
  • ODOO_QUEUE_JOB_PORT=8069, default --http-port
  • +
  • ODOO_QUEUE_JOB_SCHEME=https, default http
  • +
  • ODOO_QUEUE_JOB_HOST=load-balancer, default +--http-interface or localhost if unset
  • +
  • ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner, default empty
  • +
  • ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t, default empty
  • Start Odoo with --load=web,queue_job and --workers greater than 1. [1]
  • + +
  • Using the Odoo configuration file:
  • @@ -477,7 +482,12 @@ 

    Configuration

    (...) [queue_job] -channels = root:2 +channels = root:2 +scheme = https +host = load-balancer +port = 443 +http_auth_user = jobrunner +http_auth_password = s3cr3t