From e2376436495e4a3093a079e6a2a32ff3a74191ae Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Aug 2026 17:59:36 +0200 Subject: [PATCH 1/2] [ADD] queue_job: add jobrunner config on channels Not used at this point by the jobrunner, but the changes on channels trigger a notify to the jobrunner. --- queue_job/jobrunner/channels.py | 1 + queue_job/models/queue_job_channel.py | 101 +++++++++++++++- queue_job/tests/test_model_job_channel.py | 127 ++++++++++++++++++++ queue_job/views/queue_job_channel_views.xml | 80 +++++++++--- 4 files changed, 291 insertions(+), 18 deletions(-) diff --git a/queue_job/jobrunner/channels.py b/queue_job/jobrunner/channels.py index 7d6642dab8..36e77bee07 100644 --- a/queue_job/jobrunner/channels.py +++ b/queue_job/jobrunner/channels.py @@ -10,6 +10,7 @@ from ..exception import ChannelNotFound from ..job import CANCELLED, DONE, ENQUEUED, FAILED, PENDING, STARTED, WAIT_DEPENDENCIES +RELOAD_PAYLOAD = "reload" NOT_DONE = (WAIT_DEPENDENCIES, PENDING, ENQUEUED, STARTED, FAILED) JobSortingKey = namedtuple("SortingKey", "eta priority date_created seq") diff --git a/queue_job/models/queue_job_channel.py b/queue_job/models/queue_job_channel.py index 4aabb0188c..96dc8e9d8e 100644 --- a/queue_job/models/queue_job_channel.py +++ b/queue_job/models/queue_job_channel.py @@ -4,12 +4,28 @@ from odoo import _, api, exceptions, fields, models +from ..jobrunner.channels import RELOAD_PAYLOAD + class QueueJobChannel(models.Model): _name = "queue.job.channel" _description = "Job Channels" _rec_name = "complete_name" + # fields that trigger a reload of the jobrunner for this database when changed + _JOBRUNNER_CONFIG_FIELDS = frozenset( + ( + "capacity", + "sequential", + "throttle", + "paused", + "name", + "parent_id", + "capacity_default", + "sequential_default", + ) + ) + name = fields.Char() complete_name = fields.Char( compute="_compute_complete_name", store=True, readonly=True, recursive=True @@ -23,13 +39,68 @@ class QueueJobChannel(models.Model): string="Job Functions", ) removal_interval = fields.Integer( - default=lambda self: self.env["queue.job"]._removal_interval, required=True + default=lambda self: self.env["queue.job"]._removal_interval, + required=True, + help="Number of days after which done jobs are deleted.", + ) + capacity = fields.Integer( + help="Maximum number of jobs running at the same time in this channel. " + "0 means no limit, but they are still limited by the capacity of the parent " + "channel. On the root channel, 0 is limited by the global server-side " + "configuration." + ) + sequential = fields.Boolean( + help="Jobs are executed one after the other and failed jobs block the channel. " + "Requires a capacity of 1." + ) + throttle = fields.Integer( + help="Minimum delay in seconds between the start of two jobs in this channel." + ) + paused = fields.Boolean( + help="A paused channel (an its sub-channels) do not execute any jobs until " + "resumed." + ) + capacity_default = fields.Integer( + help="Default capacity for unconfigured sub-channels. " + "0 means they would have the same capacity as the current channel." + ) + sequential_default = fields.Boolean( + help="If sequential is enabled for unconfigured sub-channels." ) _sql_constraints = [ ("name_uniq", "unique(complete_name)", "Channel complete name must be unique") ] + @api.constrains( + "capacity", "sequential", "throttle", "capacity_default", "sequential_default" + ) + def _check_jobrunner_configuration(self): + for record in self: + if record.capacity < 0: + raise exceptions.ValidationError( + self.env._("The capacity of a channel cannot be negative.") + ) + if record.throttle < 0: + raise exceptions.ValidationError( + self.env._("The throttle of a channel cannot be negative.") + ) + if record.sequential and record.capacity != 1: + raise exceptions.ValidationError( + self.env._("A sequential channel must have a capacity of 1.") + ) + if record.capacity_default < 0: + raise exceptions.ValidationError( + self.env._("The default capacity of a channel cannot be negative.") + ) + if record.sequential_default and record.capacity_default != 1: + raise exceptions.ValidationError( + self.env._( + "A channel with a sequential default must have a " + "default capacity of 1." + ) + ) + @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): for record in self: @@ -70,8 +141,17 @@ def create(self, vals_list): new_vals_list.append(vals) vals_list = new_vals_list records |= super().create(vals_list) + records._notify_channel_config_changed() return records + @api.onchange("capacity") + def _onchange_capacity(self): + self.capacity_default = self.capacity + + @api.onchange("sequential") + def _onchange_sequential(self): + self.sequential_default = self.sequential + def write(self, values): for channel in self: if ( @@ -80,10 +160,25 @@ def write(self, values): and ("name" in values or "parent_id" in values) ): raise exceptions.UserError(_("Cannot change the root channel")) - return super().write(values) + res = super().write(values) + if self._JOBRUNNER_CONFIG_FIELDS.intersection(values): + self._notify_channel_config_changed() + return res def unlink(self): for channel in self: if channel.name == "root": raise exceptions.UserError(_("Cannot remove the root channel")) - return super().unlink() + res = super().unlink() + self._notify_channel_config_changed() + return res + + def action_pause(self): + self.write({"paused": True}) + + def action_resume(self): + self.write({"paused": False}) + + def _notify_channel_config_changed(self): + """Notify the jobrunner to reload its configuration""" + self.env.cr.execute("SELECT pg_notify('queue_job', %s)", (RELOAD_PAYLOAD,)) diff --git a/queue_job/tests/test_model_job_channel.py b/queue_job/tests/test_model_job_channel.py index 20ebbc0bfe..37a1f89485 100644 --- a/queue_job/tests/test_model_job_channel.py +++ b/queue_job/tests/test_model_job_channel.py @@ -1,9 +1,12 @@ # copyright 2018 Camptocamp # license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +from unittest import mock + from psycopg2 import IntegrityError import odoo +from odoo import exceptions from odoo.tests import common @@ -57,3 +60,127 @@ def test_channel_display_name(self): {"name": "test", "parent_id": self.root_channel.id} ) self.assertEqual(channel.display_name, channel.complete_name) + + def test_capacity_should_not_be_negative(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "The capacity of a channel cannot be negative.", + ): + self.Channel.create( + { + "name": "test_capacity", + "parent_id": self.root_channel.id, + "capacity": -1, + } + ) + + def test_capacity_default_should_not_be_negative(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "The default capacity of a channel cannot be negative.", + ): + self.Channel.create( + { + "name": "test_capacity_default", + "parent_id": self.root_channel.id, + "capacity_default": -1, + } + ) + + def test_throttle_should_not_be_negative(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "The throttle of a channel cannot be negative.", + ): + self.Channel.create( + { + "name": "test_throttle", + "parent_id": self.root_channel.id, + "throttle": -1, + } + ) + + def test_sequential_should_have_capacity_one(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "A sequential channel must have a capacity of 1.", + ): + self.Channel.create( + { + "name": "test_sequential", + "parent_id": self.root_channel.id, + "sequential": True, + "capacity": 2, + } + ) + + def test_sequential_default_should_have_capacity_default_one(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "A channel with a sequential default must have a default capacity of 1.", + ): + self.Channel.create( + { + "name": "test_sequential_default", + "parent_id": self.root_channel.id, + "sequential_default": True, + "capacity_default": 2, + } + ) + + def test_action_pause(self): + self.root_channel.action_pause() + self.assertTrue(self.root_channel.paused) + + def test_action_resume(self): + self.root_channel.paused = True + self.root_channel.action_resume() + self.assertFalse(self.root_channel.paused) + + def _patch_notify(self): + return mock.patch.object( + type(self.Channel), "_notify_channel_config_changed", autospec=True + ) + + def test_notify_create_channel(self): + with self._patch_notify() as notify: + self.Channel.create( + { + "name": "create_notify", + "parent_id": self.root_channel.id, + "capacity": 2, + } + ) + notify.assert_called_once() + + def test_notify_write_jobrunner_config(self): + channel = self.Channel.create( + {"name": "write_notify", "parent_id": self.root_channel.id} + ) + with self._patch_notify() as notify: + channel.capacity = 3 + notify.assert_called_once() + + with self._patch_notify() as notify: + channel.paused = True + notify.assert_called_once() + + with self._patch_notify() as notify: + channel.removal_interval = 60 + notify.assert_not_called() + + with self._patch_notify() as notify: + channel.capacity_default = 1 + notify.assert_called_once() + + with self._patch_notify() as notify: + channel.sequential_default = True + notify.assert_called_once() + + def test_notify_unlink_channel(self): + channel = self.Channel.create( + {"name": "unlink_notify", "parent_id": self.root_channel.id} + ) + with self._patch_notify() as notify: + channel.unlink() + notify.assert_called_once() diff --git a/queue_job/views/queue_job_channel_views.xml b/queue_job/views/queue_job_channel_views.xml index 50c245716b..70e309531f 100644 --- a/queue_job/views/queue_job_channel_views.xml +++ b/queue_job/views/queue_job_channel_views.xml @@ -5,23 +5,66 @@ queue.job.channel
- - +