[PEAK] Commands, Actions, Undo/Redo, and transactions

Phillip J. Eby pje at telecommunity.com
Wed Jul 28 16:50:52 EDT 2004


I've pretty much decided that "command" sucks as a term and as a metaphor 
for how workspaces will track undo/redo and transactions.  For one thing, 
most of what I've been calling commands aren't truly commands in the 
"Command Pattern" sense, since they don't know how to "execute themselves".

So, I'm going to define a different terminology:


   class IAction(Interface):
       """A behavior unit that can be undone or redone"""

       undoable = Attribute("""True if this action can potentially be 
undone""")

       key = Attribute(
           """A unique key identifying the subject of this action, or None"""
       )

       def undo():
           """Undo the behavior"""

       def redo():
           """Reapply the behavior"""


   class IActionGroup(IAction):

       """A list of actions that will be undone/redone as a group"""

       def __contains__(key):
           """Does this action group have an action keyed as 'key'?"""

       def __iter__():
           """Yield the actions that comprise the group"""

       active = Attribute(
           """True until 'finish()' or 'undo()' is called on this group"""
       )

       def add(action):
           """Add IAction action to the group

           Raise an error if group is no longer active.  If 'action' is
           an 'IActionGroup', recursively invoke 'add()' on all of its
           contents, except those whose keys are already present in this
           group.
           """

       def finish():
           """Stop allowing actions to be added (also called by 'undo()')"""



   class IUndoManager(Interface):

       """Perform undo/redo of recorded actions"""

       def undoLast():
           """Undo the last action added to the undo stack
           (while moving it to the redo stack)"""

       def redoNext():
           """Pop an action from the redo stack and redo it
           (while moving it to the undo stack)"""

       def add(actionGroup):
           """Record IActionGroup actionGroup as part of the history

           (add to undo stack, clear redo stack, and if action
            isn't undoable, clear the undo stack too.)"""

       # XXX haveUndoables(), haveRedoables(), clear()?


The above is still a bit rough, as I'm not yet 100% certain of how the 
state machine for histories should work.  Anyway, each workspace will have 
a "current action", which will be an IActionGroup, and an "undo manager", 
which will be an IUndoManager.  Performing operations on objects in the 
workspace will cause IAction objects to be added to the IActionGroup, 
potentially updating the IActionGroup's mementos in the process.

User-level undo and redo will of course use the undo manager.  When each 
user-level action is completed, it should call workspace.commitAction() to 
'finish()' the current action, add it to the undo manager, and create a new 
"current action".  If an action in progress has to be aborted, 
workspace.rollbackAction() should 'undo()' the current action and replace 
it with a new "current action".  (Notice that user-level undo/redo apply 
only to actions that have actually been completed; rollback of an 
in-progress action is not the same thing as 'undoLast()'.)

For typical database-backed applications, each top-level action will 
correspond to a transaction.  A commit will invoke commitAction(), an abort 
will rollbackAction(), and the undo manager will be a "null object" that 
doesn't keep any actual undo history.

Some applications may want nested transactions, in which case they'll need 
to temporarily replace the current action and undo manager with 
substitutes, and put them back when the nested transactions are 
completed.  Because there are so few real use cases for nested transactions 
(translation: I don't personally have any) the framework probably won't 
have any convenience APIs for this, though contributions would be 
considered from folks who *do* use nested transactions.  There may be some 
interesting issues to work through for use cases like aborting an action 
that has a nested action in progress, but as far as I can tell the 
interfaces I've defined are *capable* of implementing all such 
scenarios.  It's just that depending on the kind of "nested transaction 
API" you want to build, it may not be *easy*.  :)

Anyway, that's the high-level overview; let's dip into the details for a 
moment.  There are two kinds of actions that can be recorded: state-based 
and change-based.  A state-based action just records the "before" state of 
the action (or "after" state for redo), and it defines a *key* to indicate 
what piece of state (such as an object's dictionary or an index key) to 
identify the action.

The idea here is that an operation like changing an object's attribute can 
check to see if the current action contains the key, and if not, add an 
action to restore the state:

     key = id(ob) 	# XXX just an example, real keys will be more complex!
     if key not in ws.currentAction:
         ws.currentAction.add(Memento(key,ob))

The idea here is that code like this will be part of the generic functions 
surrounding change operations.  'Memento(ob)' would create an IAction that 
saves the object's "before" state.  When undone or redone, the memento 
simply swaps the object's current state with the saved state.  (Of course, 
it would also need to keep track of whether it's in "before" or "after" 
mode and accept or refuse the undo/redo accordingly.)

Because a state-based action simply puts things back the way they were when 
undone, there's no need to have more than one state-based action per target 
state per action group.  That's what the 'key' is for.  You don't create a 
new action if the group already has an action for that key, and when the 
group is added (committed) to a larger group, it can omit any actions that 
already have a matching action in the larger group.  (Because action groups 
are undone or redone as a unit, there's no need to keep an action that 
represents a snapshot somewhere in the middle of the action.)

Change-based actions are simpler than state-based actions, but they can 
consume more time and space if objects change frequently within a top-level 
action group.  Change-based actions only know how to do something forwards 
or backwards, and they are always added to the current action group 
regardless of whether similar actions already appear in the group.  They 
have a key of 'None', so they also don't get aggregated when an action 
group is merged with a parent action group.

Error handling is going to be interesting for IAction and IActionGroup 
implementors, because an action group *must* be undone or redone 
completely.  If a contained action's undo() or redo() fails, the entire 
workspace must be considered *unstable* and *unusable*.  (At a minimum, all 
action-related functions should be disabled, especially 
commitAction/rollbackAction.)  If the workspace is associated with a 
transaction, the transaction must also be marked as corrupt.  These rules 
are harsh, but failure really shouldn't be an option here.  In practice, 
most undo()/redo() methods will be: 1) fairly trivial, and 2) supplied by 
PEAK, which means they'll be tested by many PEAK users and unlikely to fail 
unless the workspace really *is* corrupted in some way.  (Action classes in 
general are part of the mapping layer, not client code or the abstract 
model, so few developers will have to deal with their trickiness directly.)

Whew!  I think that about covers it.  I think this actually constitutes a 
"best of breed" API, in that it covers scenarios ranging from object 
prevalence, to GUIs with undo/redo, to "typical" database applications, to 
esoteric nested transaction needs.

Actually, I suppose that ZODB offers a comparable array of options, 
especially ZODB4 with nested transactions, but this is a lot simpler in the 
design and should even be simpler in the implementation than ZODB's version 
of these concepts.  ZODB also offers the notion of "versions" (branches of 
history), although they would like to phase that capability out.  PEAK 
isn't going to offer a built-in equivalent, but I can see how in principle 
one could write a "versioning undo manager" and maybe some other facilities 
to allow something like it to exist in certain circumstances.

Hmm...  actually, it'd probably be more useful just to implement a 
"time-travelling" workspace that knows "as of" what version or date you are 
looking at the underlying DB.  Now there's an exciting idea for some 
applications -- but not one that's going to get into the core any time soon!

Ah well.  As usual, comments and questions are appreciated.




More information about the PEAK mailing list