diff --git a/queue_job_profiler/README.rst b/queue_job_profiler/README.rst new file mode 100644 index 000000000..a2530f7ec --- /dev/null +++ b/queue_job_profiler/README.rst @@ -0,0 +1,97 @@ +================== +Job Queue Profiler +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b6e605728ea05be0bf98dbe6e94c197bab21882e965286ef214f719e252b444e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fqueue-lightgray.png?logo=github + :target: https://github.com/OCA/queue/tree/18.0/queue_job_profiler + :alt: OCA/queue +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/queue-18-0/queue-18-0-queue_job_profiler + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/queue&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon adds profiling controls to queue job functions and wraps +queue job execution in an Odoo profiler session when enabled. + +When profiling is enabled for a job function and the executing user +matches one of the configured profiling users (or no users are set), the +queue job runner records SQL and async stack traces via +``odoo.tools.profiler.Profiler`` and saves the results into +``ir_profile``. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Open the Queue Job Functions menu. +2. Open the job function you want to profile. +3. In the Profiler group, enable Profiling and set the Profiling users + (optional) and Profiling until. +4. Run the job with the selected user (the queue job runner usually + executes as the superuser). +5. Inspect the generated entries in ``ir_profile`` to review the + captured profiling data. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- `Camptocamp `__: + + - Simone Orsi + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/queue `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/queue_job_profiler/__init__.py b/queue_job_profiler/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/queue_job_profiler/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/queue_job_profiler/__manifest__.py b/queue_job_profiler/__manifest__.py new file mode 100644 index 000000000..4b27f4dd8 --- /dev/null +++ b/queue_job_profiler/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl + +{ + "name": "Job Queue Profiler", + "version": "18.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/queue", + "license": "AGPL-3", + "category": "Generic Modules", + "depends": [ + "queue_job", + ], + "data": [ + "views/queue_job_function_views.xml", + "views/queue_job_views.xml", + ], +} diff --git a/queue_job_profiler/controllers/__init__.py b/queue_job_profiler/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/queue_job_profiler/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/queue_job_profiler/controllers/main.py b/queue_job_profiler/controllers/main.py new file mode 100644 index 000000000..57d7576cc --- /dev/null +++ b/queue_job_profiler/controllers/main.py @@ -0,0 +1,31 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tools.profiler import Profiler + +from odoo.addons.queue_job.controllers.main import RunJobController as BaseController + + +class RunJobController(BaseController): + def _try_perform_job(self, env, job): + if self._profiler_is_enabled(env, job): + return self._profiler_perform_job(env, job) + return super()._try_perform_job(env, job) + + def _profiler_is_enabled(self, env, job): + func_id = job.job_config.job_function_id + job_function = env["queue.job.function"].browse(func_id) + return job_function.is_profiling_enabled() + + def _profiler_perform_job(self, env, job): + with self._profiler_get(env, job): + result = super()._try_perform_job(env, job) + return result + + def _profiler_get(self, env, job): + func_id = job.job_config.job_function_id + job_function = env["queue.job.function"].browse(func_id) + return Profiler( + description=job_function._profile_make_name(job), + profile_session=f"{env.user.name} (uid={env.user.id})", + ) diff --git a/queue_job_profiler/models/__init__.py b/queue_job_profiler/models/__init__.py new file mode 100644 index 000000000..2cf450348 --- /dev/null +++ b/queue_job_profiler/models/__init__.py @@ -0,0 +1,2 @@ +from . import queue_job_function +from . import queue_job diff --git a/queue_job_profiler/models/queue_job.py b/queue_job_profiler/models/queue_job.py new file mode 100644 index 000000000..cc2ddc7e8 --- /dev/null +++ b/queue_job_profiler/models/queue_job.py @@ -0,0 +1,43 @@ +# Copyright 2019 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class QueueJob(models.Model): + _inherit = "queue.job" + + job_is_profiled = fields.Boolean( + string="Profiled", + default=False, + help="Whether this job has been profiled or not.", + compute="_compute_job_is_profiled", + ) + + def _compute_job_is_profiled(self): + for job in self: + # don't care about perf as this is loaded only on the job form view + profile_name = job.job_function_id._profile_make_name(job) + job.job_is_profiled = bool(job._profiler_get_record(profile_name)) + + def _profiler_get_record(self, profile_name): + IrProfile = self.env["ir.profile"] + return IrProfile.search( + [("name", "=", profile_name)], + limit=1, + ) + + def action_view_profile(self): + self.ensure_one() + profile_name = self.job_function_id._profile_make_name(self) + profile = self._profiler_get_record(profile_name) + if not profile: + return {"type": "ir.actions.act_window_close"} + return { + "type": "ir.actions.act_window", + "name": "Profile", + "res_model": "ir.profile", + "view_mode": "form", + "res_id": profile.id, + "target": "current", + } diff --git a/queue_job_profiler/models/queue_job_function.py b/queue_job_profiler/models/queue_job_function.py new file mode 100644 index 000000000..9118d05cd --- /dev/null +++ b/queue_job_profiler/models/queue_job_function.py @@ -0,0 +1,47 @@ +# Copyright 2019 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import api, exceptions, fields, models + + +class QueueJobFunction(models.Model): + _inherit = "queue.job.function" + + profiling_enabled = fields.Boolean( + string="Profiling enabled", + help="Indicates whether profiling is enabled for this job function.", + ) + profiling_user_ids = fields.Many2many( + "res.users", + string="Profiling users", + help="The users allowed to perform profiling for this job function.", + ) + profiling_until = fields.Datetime( + string="Profiling until", + help="The date and time until which profiling is enabled " + "for this job function.", + ) + + def is_profiling_enabled(self): + self.ensure_one() + return ( + self.profiling_enabled + and (self.profiling_until and self.profiling_until >= fields.Datetime.now()) + and ( + not self.profiling_user_ids or self.env.user in self.profiling_user_ids + ) + ) + + @api.constrains("profiling_enabled") + def _check_profiling_setup(self): + for record in self: + if record.profiling_enabled and not record.profiling_until: + raise exceptions.ValidationError( + self.env._( + "A profiling until date must be set when profiling is enabled." + ) + ) + + def _profile_make_name(self, job): + self.ensure_one() + return f"queue.job {job.uuid} - {self.name}" diff --git a/queue_job_profiler/pyproject.toml b/queue_job_profiler/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/queue_job_profiler/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/queue_job_profiler/readme/CONTRIBUTORS.md b/queue_job_profiler/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..fc27d9f59 --- /dev/null +++ b/queue_job_profiler/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://camptocamp.com): + - Simone Orsi \<\> diff --git a/queue_job_profiler/readme/DESCRIPTION.md b/queue_job_profiler/readme/DESCRIPTION.md new file mode 100644 index 000000000..06cb1376d --- /dev/null +++ b/queue_job_profiler/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This addon adds profiling controls to queue job functions and wraps +queue job execution in an Odoo profiler session when enabled. + +When profiling is enabled for a job function and the executing user +matches one of the configured profiling users (or no users are set), +the queue job runner records +SQL and async stack traces via `odoo.tools.profiler.Profiler` and saves +the results into `ir_profile`. \ No newline at end of file diff --git a/queue_job_profiler/readme/USAGE.md b/queue_job_profiler/readme/USAGE.md new file mode 100644 index 000000000..a58ee8eef --- /dev/null +++ b/queue_job_profiler/readme/USAGE.md @@ -0,0 +1,8 @@ +1. Open the Queue Job Functions menu. +2. Open the job function you want to profile. +3. In the Profiler group, enable Profiling and set the Profiling users + (optional) and Profiling until. +4. Run the job with the selected user (the queue job runner usually executes + as the superuser). +5. Inspect the generated entries in `ir_profile` to review the captured + profiling data. \ No newline at end of file diff --git a/queue_job_profiler/static/description/index.html b/queue_job_profiler/static/description/index.html new file mode 100644 index 000000000..7a850cb7c --- /dev/null +++ b/queue_job_profiler/static/description/index.html @@ -0,0 +1,446 @@ + + + + + +Job Queue Profiler + + + +
+

Job Queue Profiler

+ + +

Beta License: AGPL-3 OCA/queue Translate me on Weblate Try me on Runboat

+

This addon adds profiling controls to queue job functions and wraps +queue job execution in an Odoo profiler session when enabled.

+

When profiling is enabled for a job function and the executing user +matches one of the configured profiling users (or no users are set), the +queue job runner records SQL and async stack traces via +odoo.tools.profiler.Profiler and saves the results into +ir_profile.

+

Table of contents

+ +
+

Usage

+
    +
  1. Open the Queue Job Functions menu.
  2. +
  3. Open the job function you want to profile.
  4. +
  5. In the Profiler group, enable Profiling and set the Profiling users +(optional) and Profiling until.
  6. +
  7. Run the job with the selected user (the queue job runner usually +executes as the superuser).
  8. +
  9. Inspect the generated entries in ir_profile to review the +captured profiling data.
  10. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/queue project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/queue_job_profiler/tests/__init__.py b/queue_job_profiler/tests/__init__.py new file mode 100644 index 000000000..f4cae97b5 --- /dev/null +++ b/queue_job_profiler/tests/__init__.py @@ -0,0 +1 @@ +from . import test_job_profiler diff --git a/queue_job_profiler/tests/test_job_profiler.py b/queue_job_profiler/tests/test_job_profiler.py new file mode 100644 index 000000000..6844ee1e2 --- /dev/null +++ b/queue_job_profiler/tests/test_job_profiler.py @@ -0,0 +1,142 @@ +# copyright 2026 Camptocamp sa (https://www.camptocamp.com). +# license AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from datetime import timedelta +from unittest.mock import patch + +from freezegun import freeze_time + +from odoo import exceptions, fields +from odoo.tests import common, new_test_user + +from odoo.addons.queue_job_profiler.controllers.main import RunJobController + + +class TestJobFunction(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.user_func1 = cls.env["queue.job.function"].create( + {"model_id": cls.env["ir.model"]._get_id("res.users"), "method": "read"} + ) + cls.user_func2 = cls.env["queue.job.function"].create( + {"model_id": cls.env["ir.model"]._get_id("res.users"), "method": "read"} + ) + cls.user1 = new_test_user(cls.env, login="user1", groups="base.group_user") + cls.user2 = new_test_user(cls.env, login="user2", groups="base.group_user") + + def test_func_constraint(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "A profiling until date must be set when profiling is enabled.", + ): + self.user_func1.profiling_enabled = True + + def _enable_profiling(self, job_function, users=None, delta=timedelta(hours=1)): + job_function.profiling_user_ids = users + job_function.profiling_until = fields.Datetime.now() + delta + job_function.profiling_enabled = True + + def test_func_profiling_enabled(self): + # By default, profiling should be disabled for all users + for user in (self.env.user, self.user1, self.user2): + self.assertFalse(self.user_func1.with_user(user).is_profiling_enabled()) + # Enable for all users (no profiling users set) + self._enable_profiling(self.user_func1) + for user in (self.env.user, self.user1, self.user2): + self.assertTrue(self.user_func1.with_user(user).is_profiling_enabled()) + # Enable it for user1 and user2 + self._enable_profiling(self.user_func1, users=self.user1 + self.user2) + self.assertTrue(self.user_func1.with_user(self.user1).is_profiling_enabled()) + self.assertTrue(self.user_func1.with_user(self.user2).is_profiling_enabled()) + self.assertFalse( + self.user_func1.with_user(self.env.user).is_profiling_enabled() + ) + # Check for another job function and user + self._enable_profiling(self.user_func2, users=self.user2) + self.assertTrue(self.user_func2.with_user(self.user2).is_profiling_enabled()) + for user in (self.env.user, self.user1): + self.assertFalse(self.user_func2.with_user(user).is_profiling_enabled()) + with freeze_time(fields.Datetime.now() + timedelta(days=1)): + # After the profiling_until date, profiling should be disabled for all users + for user in (self.env.user, self.user1, self.user2): + self.assertFalse(self.user_func1.with_user(user).is_profiling_enabled()) + self.assertFalse(self.user_func2.with_user(user).is_profiling_enabled()) + + def test_job_is_profiled(self): + job1 = self.env.user.with_delay().read(["id"]) + job1.store() + job_rec1 = job1.db_record() + job2 = self.env.user.with_delay().read(["id"]) + job2.store() + job_rec2 = job2.db_record() + jobs = job_rec1 | job_rec2 + self.assertEqual(jobs.mapped("job_is_profiled"), [False, False]) + profile_name1 = self.user_func1._profile_make_name(job_rec1) + profile_name2 = self.user_func2._profile_make_name(job_rec2) + prof1 = self.env["ir.profile"].create({"name": profile_name1}) + jobs.invalidate_recordset() + self.assertEqual(jobs.mapped("job_is_profiled"), [True, False]) + prof2 = self.env["ir.profile"].create({"name": profile_name2}) + jobs.invalidate_recordset() + self.assertEqual(jobs.mapped("job_is_profiled"), [True, True]) + self.assertEqual(job_rec1._profiler_get_record(profile_name1), prof1) + self.assertEqual(job_rec2._profiler_get_record(profile_name2), prof2) + + def test_job_action_view_profile(self): + job = self.env.user.with_delay().read(["id"]) + job.store() + job_rec = job.db_record() + self.assertEqual( + job_rec.action_view_profile(), {"type": "ir.actions.act_window_close"} + ) + profile_name = self.user_func1._profile_make_name(job_rec) + prof = self.env["ir.profile"].create({"name": profile_name}) + action = job_rec.action_view_profile() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "ir.profile") + self.assertEqual(action["res_id"], prof.id) + + def _run_controller(self, job, user=None): + controller = RunJobController() + env = self.env(user=user) if user else self.env + with ( + patch.object(self.env.cr, "commit"), + ): + controller._try_perform_job(env, job) + + def test_controller(self): + job = self.env.user.with_delay().read(["id"]) + self._enable_profiling(self.user_func1, [self.user1.id]) + # TODO @simahawk: I'd like to look for new `ir_profile` records + # but somehow looks very hard to find them + # since `Profiler` creates them in a separated db connection. + # All my attempts to mock the cr or de connection of `db_connect` + # failed poorly, so I prefer to not waste too much time aroudn this. + # I'm falling back to patching the controller method + # that should be called when profiling is enabled. + # The profiler init is tested in another method below. + # Not ideal, but better than no test :) + with patch.object(RunJobController, "_profiler_get") as mock_profiler_get: + self._run_controller(job) + mock_profiler_get.assert_not_called() + with patch.object(RunJobController, "_profiler_get") as mock_profiler_get: + self._run_controller(job, user=self.user1) + mock_profiler_get.assert_called_once() + with freeze_time(fields.Datetime.now() + timedelta(days=1)): + with patch.object(RunJobController, "_profiler_get") as mock_profiler_get: + self._run_controller(job) + mock_profiler_get.assert_not_called() + + def test_controller_profiler_get(self): + # as we mocked it before, make sure it works + controller = RunJobController() + job = self.env.user.with_delay().read(["id"]) + profiler = controller._profiler_get(self.env, job) + self.assertEqual( + profiler.description, f"queue.job {job.uuid} - {job.job_function_name}" + ) + self.assertEqual( + profiler.profile_session, f"{self.env.user.name} (uid={self.env.user.id})" + ) diff --git a/queue_job_profiler/views/queue_job_function_views.xml b/queue_job_profiler/views/queue_job_function_views.xml new file mode 100644 index 000000000..b35156008 --- /dev/null +++ b/queue_job_profiler/views/queue_job_function_views.xml @@ -0,0 +1,24 @@ + + + + queue.job.function.form.profiler + queue.job.function + + + + + + + + + + + + diff --git a/queue_job_profiler/views/queue_job_views.xml b/queue_job_profiler/views/queue_job_views.xml new file mode 100644 index 000000000..7e379a379 --- /dev/null +++ b/queue_job_profiler/views/queue_job_views.xml @@ -0,0 +1,25 @@ + + + + queue.job.form.profiler + queue.job + + + + + + + +