Skip to content

Commit

Permalink
Add method to patch a method to be automatically delayed
Browse files Browse the repository at this point in the history
This patch method has to be called in ``_register_hook``.

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.

It relies on OCA#274 that deprecates the
`@job` decorator.
  • Loading branch information
guewen committed Nov 2, 2020
1 parent 013b7a6 commit de019cc
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 0 deletions.
92 changes: 92 additions & 0 deletions queue_job/models/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
27 changes: 27 additions & 0 deletions test_queue_job/models/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
1 change: 1 addition & 0 deletions test_queue_job/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions test_queue_job/tests/test_job_auto_delay.py
Original file line number Diff line number Diff line change
@@ -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 <method>_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")

0 comments on commit de019cc

Please sign in to comment.