From 072b82c4c815bd4ac3b09083c0b4a3f97a0f04de Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 14 Aug 2026 16:21:37 +0200 Subject: [PATCH 01/22] [IMP] queue_job: add pause option on channels Add a new "paused" option on channel configuration. A paused channel is equivalent to a channel with no capacity: it yields no job and also blocks all its subchannels. It still needs a restart of the jobrunner, so it does not allow dynamic pause/resume, but hot reload may be added later in OCA/queue#765. It still is an improvement over setting the capacity at 0, because it keeps the capacity in the configuration (removing the option in the config restores the initial capacity). --- queue_job/jobrunner/channels.py | 40 +++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/queue_job/jobrunner/channels.py b/queue_job/jobrunner/channels.py index bfc8984862..b273b9cf47 100644 --- a/queue_job/jobrunner/channels.py +++ b/queue_job/jobrunner/channels.py @@ -402,9 +402,14 @@ class Channel: with a capacity of 1. It is also possible to dedicate a channel with a limited capacity for application-autocreated subchannels without risking to overflow the system. + + A paused channel does not process any job until it is resumed. All subchannels + are blocked with their parent channel. """ - def __init__(self, name, parent, capacity=None, sequential=False, throttle=0): + def __init__( + self, name, parent, capacity=None, sequential=False, throttle=0, paused=False + ): self.name = name self.parent = parent if self.parent: @@ -417,6 +422,7 @@ def __init__(self, name, parent, capacity=None, sequential=False, throttle=0): self.capacity = capacity self.throttle = throttle # seconds self.sequential = sequential + self.paused = paused @property def sequential(self): @@ -434,11 +440,13 @@ def configure(self, config): * capacity * sequential * throttle + * paused """ assert self.fullname.endswith(config["name"]) self.capacity = config.get("capacity", None) self.sequential = bool(config.get("sequential", False)) self.throttle = int(config.get("throttle", 0)) + self.paused = bool(config.get("paused", False)) if self.sequential and self.capacity != 1: raise ValueError("A sequential channel must have a capacity of 1") @@ -455,9 +463,10 @@ def get_subchannel_by_name(self, subchannel_name): def __str__(self): capacity = "∞" if self.capacity is None else str(self.capacity) + paused = ",paused" if self.paused else "" return ( f"{self.fullname}(C:{capacity},Q:{len(self._queue)}," - f"R:{len(self._running)},F:{len(self._failed)})" + f"R:{len(self._running)},F:{len(self._failed)}{paused})" ) def remove(self, job): @@ -514,6 +523,8 @@ def set_failed(self, job): _logger.debug("job %s marked failed in channel %s", job.uuid, self) def has_capacity(self): + if self.paused: + return False if self.sequential and self._failed: # a sequential queue blocks on failed jobs return False @@ -796,6 +807,31 @@ class ChannelManager: >>> cm.notify(db, 'S', 'S3', 3, 0, 10, None, 'done') >>> pp(list(cm.get_jobs_to_run(now=105))) [] + + Test pausing a channel + + >>> cm = ChannelManager() + >>> cm.simple_configure('root:4,P:2:paused,P.sub:1') + >>> cm.notify(db, 'P', 'P1', 1, 0, 10, None, 'pending') + >>> cm.notify(db, 'P.sub', 'PS1', 2, 0, 10, None, 'pending') + + Paused channel yields no job + + >>> pp(list(cm.get_jobs_to_run(now=100))) + [] + + Resuming the channel yields the pending jobs + + >>> cm.simple_configure('root:4,P:2') + >>> pp(list(cm.get_jobs_to_run(now=100))) + [, ] + + Pausing the root channel blocks everything + + >>> cm.simple_configure('root:4:paused') + >>> cm.notify(db, 'P', 'P3', 4, 0, 10, None, 'pending') + >>> pp(list(cm.get_jobs_to_run(now=106))) + [] """ def __init__(self): From 54e83d8e204a0dfe8ad6d0e87c5994fed4a61325 Mon Sep 17 00:00:00 2001 From: Holoborodko Bohdan Date: Tue, 15 Sep 2026 11:31:02 +0000 Subject: [PATCH 02/22] [FIX] queue_job: validate JobSerialized base_type again Odoo 19 renamed Field._setup_attrs to _setup_attrs__, so the override was no longer called and an unsupported base_type was silently accepted. --- queue_job/fields.py | 4 ++-- queue_job/tests/test_json_field.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/queue_job/fields.py b/queue_job/fields.py index d9f371605f..bb5b6597ae 100644 --- a/queue_job/fields.py +++ b/queue_job/fields.py @@ -42,8 +42,8 @@ class JobSerialized(fields.Json): def __init__(self, string=SENTINEL, base_type=SENTINEL, **kwargs): super().__init__(string=string, _base_type=base_type, **kwargs) - def _setup_attrs(self, model, name): # pylint: disable=missing-return - super()._setup_attrs(model, name) + def _setup_attrs__(self, model, name): # pylint: disable=missing-return + super()._setup_attrs__(model, name) if self._base_type not in self._default_json_mapping: msg = f"{self._base_type} is not a supported base type" raise ValueError(msg) diff --git a/queue_job/tests/test_json_field.py b/queue_job/tests/test_json_field.py index 23974e23c9..2f7c886f14 100644 --- a/queue_job/tests/test_json_field.py +++ b/queue_job/tests/test_json_field.py @@ -11,7 +11,7 @@ # pylint: disable=odoo-addons-relative-import # we are testing, we want to test as we were an external consumer of the API -from odoo.addons.queue_job.fields import JobDecoder, JobEncoder +from odoo.addons.queue_job.fields import JobDecoder, JobEncoder, JobSerialized class TestJson(common.TransactionCase): @@ -195,3 +195,9 @@ def test_decoder_etree(self): value = json.loads(value_json, cls=JobDecoder, env=self.env) value[2] = etree.tostring(value[2]) self.assertEqual(value, expected) + + def test_job_serialized_base_type_is_validated(self): + model_class = type(self.env["queue.job"]) + JobSerialized(base_type=dict)._setup_attrs__(model_class, "supported") + with self.assertRaisesRegex(ValueError, "is not a supported base type"): + JobSerialized(base_type=int)._setup_attrs__(model_class, "unsupported") From 23655b55c080ad7a1ca82717d4b9097651db3dd0 Mon Sep 17 00:00:00 2001 From: Holoborodko Bohdan Date: Tue, 15 Sep 2026 11:31:05 +0000 Subject: [PATCH 03/22] [FIX] queue_job: notify managers holding the group through implication In Odoo 19 res.users.group_ids only holds explicitly assigned groups; implied ones are exposed through all_group_ids. Users who got Job Queue Manager through another group were no longer subscribed to failed jobs. --- queue_job/models/queue_job.py | 2 +- queue_job/tests/__init__.py | 1 + .../tests/test_subscribe_users_domain.py | 39 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 queue_job/tests/test_subscribe_users_domain.py diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 23cf5de370..c426840d74 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -374,7 +374,7 @@ def _subscribe_users_domain(self): if not group: return None companies = self.mapped("company_id") - domain = [("group_ids", "=", group.id)] + domain = [("all_group_ids", "in", group.ids)] if companies: domain.append(("company_id", "in", companies.ids)) return domain diff --git a/queue_job/tests/__init__.py b/queue_job/tests/__init__.py index 696549a65a..009f183567 100644 --- a/queue_job/tests/__init__.py +++ b/queue_job/tests/__init__.py @@ -8,4 +8,5 @@ from . import test_model_job_channel from . import test_model_job_function from . import test_queue_job_protected_write +from . import test_subscribe_users_domain from . import test_wizards diff --git a/queue_job/tests/test_subscribe_users_domain.py b/queue_job/tests/test_subscribe_users_domain.py new file mode 100644 index 0000000000..fa13c834cd --- /dev/null +++ b/queue_job/tests/test_subscribe_users_domain.py @@ -0,0 +1,39 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from odoo import Command +from odoo.tests import common + + +class TestSubscribeUsersDomain(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + group_user = cls.env.ref("base.group_user") + manager_group = cls.env.ref("queue_job.group_queue_job_manager") + implying_group = cls.env["res.groups"].create( + { + "name": "Implies Job Queue Manager", + "implied_ids": [Command.link(manager_group.id)], + } + ) + Users = cls.env["res.users"] + cls.direct_manager = Users.create( + { + "name": "Direct Queue Manager", + "login": "queue_direct_manager", + "group_ids": [Command.set([group_user.id, manager_group.id])], + } + ) + cls.implied_manager = Users.create( + { + "name": "Implied Queue Manager", + "login": "queue_implied_manager", + "group_ids": [Command.set([group_user.id, implying_group.id])], + } + ) + + def test_subscribe_users_domain_includes_implied_managers(self): + domain = self.env["queue.job"]._subscribe_users_domain() + users = self.env["res.users"].search(domain) + self.assertIn(self.direct_manager, users) + self.assertIn(self.implied_manager, users) From ffacd6fca7e36c26a27ce4a518e4e54687b72664 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 5 Oct 2026 17:41:13 +0000 Subject: [PATCH 04/22] [BOT] post-merge updates --- queue_job/README.rst | 2 +- queue_job/__manifest__.py | 2 +- queue_job/static/description/index.html | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/queue_job/README.rst b/queue_job/README.rst index f9a974d192..890d52f762 100644 --- a/queue_job/README.rst +++ b/queue_job/README.rst @@ -11,7 +11,7 @@ Job Queue !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:aee74431fafa74d2abc078c3686e4d9c6390bacc001cac7c4afa01b9db77ca06 + !! source digest: sha256:f8cd4a2bc31b48b3081573ba07c2396b47bb99f749d5125d10fcde512a987212 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index 03ea411f2c..3d82bb086d 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -2,7 +2,7 @@ { "name": "Job Queue", - "version": "19.0.2.1.3", + "version": "19.0.2.1.4", "author": "Camptocamp,ACSONE SA/NV,Odoo Community Association (OCA)", "website": "https://github.com/OCA/queue", "license": "LGPL-3", diff --git a/queue_job/static/description/index.html b/queue_job/static/description/index.html index 58b23819d8..ccf5d86df4 100644 --- a/queue_job/static/description/index.html +++ b/queue_job/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Job Queue