Search code examples
pythonflattentry-finally

Maintaining a roll-backable flow of code in python without extreme identation


I've encountered a situation where I'm working over a piece of code where I command changes on a remote object (that is one I can't duplicate to work over a clone), then ask the remote object for some operation in the new state and revert all the changes I made to it by a sequence of opposite commands. The problem is that if in the middle of all these changes I encounter an error, I want to be able to roll-back all the changes I made so far.

The best fitting solution that came to my mind is the python try-finally workflow, but it's rather problematic when the sequence of commands is long:

try:
    # perform action
    try:
        # perform action
        try: 
            # ...
        finally:
            # unroll
    finally:
        # unroll
finally:
    # unroll

This way, the more commands I need the deeper my indentation and nesting goes and the less my code is readable. I've considered some other solutions such as maintaining a stack where for every command I push a rollback action, but this could get rather complicated, and I dislike pushing bound methods into stacks. I've also considered incrementing a counter for every action I perform then in a single finally decide on the kind of rollback I want depending on the counter, but again, the maintainability of such code becomes a pain.

Most hits I got on searches for "transactions" and "rollback" were DB related and didn't fit very well to a more generic kind of code... Anyone has a good idea as to how to systematically flatten this atrocity?


Solution

  • Wouldn't Context Manager objects and the with statement improve the situation? Especially if you can use a version of Python where the with statement supports multiple context expressions, as 2.7 or 3.x. Here's an example:

    class Action(object):
        def __init__(self, count):
            self.count = count
        def perform(self):
            print "perform " + str(self.count)
            if self.count == 2:
                raise Exception("self.count is " + str(self.count))
        def commit(self):
            print "commit " + str(self.count)
        def rollback(self):
            print "rollback " + str(self.count)
        def __enter__(self):
            perform()
            return self
        def __exit__(self, exc_type, exc_value, traceback):
            if exc_value is None:
                self.commit()
            else:
                self.rollback()
    
    with Action(1), Action(2), Action(3):
        pass
    

    You'd have to move your code to a set of "transactional" classes, such as Action above, where the action to be performed is executed in the __enter__() method and, if this terminates normally, you would be guaranteed that the corresponding __exit()__ method would be called.

    Note that my example doesn't correspond exactly to yours; you'd have to tune what to execute in the __enter__() methods and what to execute in the with statement's body. In that case you might want to use the following syntax:

    with Action(1) as a1, Action(2) as a2:
        pass
    

    To be able to access the Action objects from within the body of the with statement.