diff --git a/queue_job/models/base.py b/queue_job/models/base.py index 3bb4d78361..116eb495f9 100644 --- a/queue_job/models/base.py +++ b/queue_job/models/base.py @@ -1,6 +1,7 @@ # Copyright 2016 Camptocamp # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +import functools import inspect import logging import os @@ -108,3 +109,94 @@ def with_delay( channel=channel, identity_key=identity_key, ) + + def _patch_job_auto_delay(self, method_name, context_key=None): + """Patch a method to be automatically delayed as job method when called + + This patch method has to be called in ``_register_hook`` (example + below). + + When a method is patched, any call to the method will not directly + execute the method's body, but will instead enqueue a job. + + When a ``context_key`` is set when calling ``_patch_job_auto_delay``, + the patched method is automatically delayed only when this key is + ``True`` in the caller's context. It is advised to patch the method + with a ``context_key``, because making the automatic delay *in any + case* can produce nasty and unexpected side effects (e.g. another + module calls the method and expects it to be computed before doing + something else, expecting a result, ...). + + A typical use case is when a method in a module we don't control is + called synchronously in the middle of another method, and we'd like all + the calls to this method become asynchronous. + + The options of the job usually passed to ``with_delay()`` (priority, + description, identity_key, ...) can be returned in a dictionary by a + method named after the name of the method suffixed by ``_job_options`` + which takes the same parameters as the initial method. + + It is still possible to force synchronous execution of the method by + setting a key ``_job_force_sync`` to True in the environment context. + + Example patching the "foo" method to be automatically delayed as job + (the job options method is optional): + + .. code-block:: python + + # original method: + def foo(self, arg1): + print("hello", arg1) + + def large_method(self): + # doing a lot of things + self.foo("world) + # doing a lot of other things + + def button_x(self): + self.with_context(auto_delay_foo=True).large_method() + + # auto delay patch: + def foo_job_options(self, arg1): + return { + "priority": 100, + "description": "Saying hello to {}".format(arg1) + } + + def _register_hook(self): + self._patch_method( + "foo", + self._patch_job_auto_delay("foo", context_key="auto_delay_foo") + ) + return super()._register_hook() + + The result when ``button_x`` is called, is that a new job for ``foo`` + is delayed. + """ + + def auto_delay_wrapper(self, *args, **kwargs): + # when no context_key is set, we delay in any case (warning, can be + # dangerous) + context_delay = self.env.context.get(context_key) if context_key else True + if ( + self.env.context.get("job_uuid") + or not context_delay + or self.env.context.get("_job_force_sync") + or self.env.context.get("test_queue_job_no_delay") + ): + # we are in the job execution + return auto_delay_wrapper.origin(self, *args, **kwargs) + else: + # replace the synchronous call by a job on itself + method_name = auto_delay_wrapper.origin.__name__ + job_options_method = getattr( + self, "{}_job_options".format(method_name), None + ) + job_options = {} + if job_options_method: + job_options.update(job_options_method(*args, **kwargs)) + delayed = self.with_delay(**job_options) + return getattr(delayed, method_name)(*args, **kwargs) + + origin = getattr(self, method_name) + return functools.update_wrapper(auto_delay_wrapper, origin) diff --git a/test_queue_job/models/test_models.py b/test_queue_job/models/test_models.py index 36fdb1c6f9..9bd5b2c9cc 100644 --- a/test_queue_job/models/test_models.py +++ b/test_queue_job/models/test_models.py @@ -61,6 +61,33 @@ def job_alter_mutable(self, mutable_arg, mutable_kwarg=None): mutable_kwarg["b"] = 2 return mutable_arg, mutable_kwarg + def delay_me(self, arg, kwarg=None): + return arg, kwarg + + def delay_me_options_job_options(self): + return { + "identity_key": "my_job_identity", + } + + def delay_me_options(self): + return "ok" + + def delay_me_context_key(self): + return "ok" + + def _register_hook(self): + self._patch_method("delay_me", self._patch_job_auto_delay("delay_me")) + self._patch_method( + "delay_me_options", self._patch_job_auto_delay("delay_me_options") + ) + self._patch_method( + "delay_me_context_key", + self._patch_job_auto_delay( + "delay_me_context_key", context_key="auto_delay_delay_me_context_key" + ), + ) + return super()._register_hook() + class TestQueueChannel(models.Model): diff --git a/test_queue_job/tests/__init__.py b/test_queue_job/tests/__init__.py index 9af8df15a0..502a0752fd 100644 --- a/test_queue_job/tests/__init__.py +++ b/test_queue_job/tests/__init__.py @@ -1,4 +1,5 @@ from . import test_autovacuum from . import test_job +from . import test_job_auto_delay from . import test_job_channels from . import test_related_actions diff --git a/test_queue_job/tests/test_job_auto_delay.py b/test_queue_job/tests/test_job_auto_delay.py new file mode 100644 index 0000000000..41821b40e9 --- /dev/null +++ b/test_queue_job/tests/test_job_auto_delay.py @@ -0,0 +1,51 @@ +# Copyright 2020 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from odoo.addons.queue_job.job import Job + +from .common import JobCommonCase + + +class TestJobAutoDelay(JobCommonCase): + """Test auto delay of jobs""" + + def test_auto_delay(self): + """method decorated by @job_auto_delay is automatically delayed""" + result = self.env["test.queue.job"].delay_me(1, kwarg=2) + self.assertTrue(isinstance(result, Job)) + self.assertEqual(result.args, (1,)) + self.assertEqual(result.kwargs, {"kwarg": 2}) + + def test_auto_delay_options(self): + """method automatically delayed une _job_options arguments""" + result = self.env["test.queue.job"].delay_me_options() + self.assertTrue(isinstance(result, Job)) + self.assertEqual(result.identity_key, "my_job_identity") + + def test_auto_delay_inside_job(self): + """when a delayed job is processed, it must not delay itself""" + job_ = self.env["test.queue.job"].delay_me(1, kwarg=2) + self.assertTrue(job_.perform(), (1, 2)) + + def test_auto_delay_force_sync(self): + """method forced to run synchronously""" + result = ( + self.env["test.queue.job"] + .with_context(_job_force_sync=True) + .delay_me(1, kwarg=2) + ) + self.assertTrue(result, (1, 2)) + + def test_auto_delay_context_key_set(self): + """patched with context_key delays only if context keys is set""" + result = ( + self.env["test.queue.job"] + .with_context(auto_delay_delay_me_context_key=True) + .delay_me_context_key() + ) + self.assertTrue(isinstance(result, Job)) + + def test_auto_delay_context_key_unset(self): + """patched with context_key do not delay if context keys is not set""" + result = self.env["test.queue.job"].delay_me_context_key() + self.assertEqual(result, "ok")