Skip to main content

Cookbook: deactivating scheduled object on error

Suppose we have an object that has a scheduled action on it (do you know celery?) and suppose that we are far-sighted people and we know that shit happens and we want to prevent disaster removing the object from the queue of the scheduler when an exception is raised.

We are using Django and our object has an attribute active that indicates that it is available for scheduling; to manage the exception we add the attribute error that indicates an error happened on it (probably we could use a TextField and save also the exception there but for now is enough); the attribute title is here only to have a parameter to query with the object later, substitute it with the fields that you want, it's your life.

 class Object(models.Model):
    title  = models.CharField(max_length=100)

    active = models.BooleanField(default=False)
    error  = models.BooleanField(default=False)

    def deactivate(self, error=None):
        if error:
            self.error = True

        self.active = False
        self.save()

Suppose the celery task is the following

 @transaction.atomic
 def _manage_obj(pk, *args, **kwargs):
    obj = Object.objects.get(pk=pk)
    do_some_fantastic_action_on_it(obj)

the transaction.atomic decorator make possible to maintain the original state of the object before the error occurred (also for this reason is necessary to deactivate it because otherwise the next time the error will happen again 'cause determinism, you know?).

Instead of calling the method below we will use the following code: we call the original method, wrapping it around a try and except block: if an exception is raised we catch it, we retrieve the object on which the code failed, we deactivate it indicating that there was an error (probably we should write a decorator here :))

 @app.task
 def manage_object(pk, *args, **kwargs):
    '''
    This method wraps the one managing the object.

    If an exception occurs during the inner method then deactivate
    the object and re-raise the exception so that celery manages it.

    The inner method should be atomic so that the object remains in the
    state it was when the error occurred.
    '''

    try:
        _manage_object(pk, *args, **kwargs)
    except:
        obj = Object.objects.get(pk=pk)
        # get the exception context to reuse later
        exc_info = sys.exc_info()

        obj.deactivate(error=exc_info[0])
        mail_admins('Object deactivate on error', u''''%s' deactivated bc an error occurred.
        ''' % obj.title)

        # reraise exception wo losing traceback
        # http://www.ianbicking.org/blog/2007/09/re-raising-exceptions.html
        raise exc_info[0], exc_info[1], exc_info[2]

Since the exception is re-raised, it will be catched by celery that will manage it in the way is configured for; in order to not lose the original traceback the raise line has a particular form that you can deduce from the raise and the sys.exec_info() references.

Testing

Obviously the coding is nothin without testing: we need to use the mock library to fake an exception and check that the final result is what we expect (you have to set some django's settings in a particular way to make this works like in the override_settings just below)

@override_settings(
    CELERY_ALWAYS_EAGER=True,
    CELERY_EAGER_PROPAGATES_EXCEPTIONS=True,
    BROKER_BACKEND='memory', # avoid error for missing redis
)
def test_gracefull_failing(self):
    obj = ObjectFactory()
    with mock.patch('my_app.models_tools._manage_object') as mocked_manage_object:
        class KebabException(ValueError):
            pass

        mocked_manage_object.side_effect = KebabException('Kebab is not in the house')

        try:
            manage_object(obj.pk)
        except KebabException as e:
            logger.info('test with ' + str(e))

    # refresh the instance
    obj = Object.objects.get(pk=obj.pk)

    # check something here

For now is all, bye.

Comments

Comments powered by Disqus