[Zodb-checkins] SVN: ZODB/trunk/ merge http://svn.zope.org/ZODB/branches/anguenot-after_commit_hooks/ branch

Julien Anguenot ja at nuxeo.com
Thu Jan 5 16:12:03 EST 2006


Log message for revision 41164:
  merge http://svn.zope.org/ZODB/branches/anguenot-after_commit_hooks/ branch

Changed:
  U   ZODB/trunk/NEWS.txt
  U   ZODB/trunk/src/transaction/_transaction.py
  U   ZODB/trunk/src/transaction/interfaces.py
  U   ZODB/trunk/src/transaction/tests/test_transaction.py

-=-
Modified: ZODB/trunk/NEWS.txt
===================================================================
--- ZODB/trunk/NEWS.txt	2006-01-05 20:48:01 UTC (rev 41163)
+++ ZODB/trunk/NEWS.txt	2006-01-05 21:12:02 UTC (rev 41164)
@@ -46,3 +46,15 @@
 
 - (3.7a1) An optimization for loading non-current data (MVCC) was
   inadvertently disabled in ``_setstate()``; this has been repaired.
+
+After Commit hooks
+------------------
+
+- (3.7a1) Transaction objects have a new method,
+  ``addAfterCommitHook(hook, *args, **kws)``.  Hook functions
+  registered with a transaction are called after the transaction
+  commits or aborts. For example, one might want to launch non
+  transactional or asynchrnonous code after a successful, or aborted,
+  commit. See ``test_afterCommitHook()`` in
+  ``transaction/tests/test_transaction.py`` for a tutorial doctest,
+  and the ``ITransaction`` interface for details.

Modified: ZODB/trunk/src/transaction/_transaction.py
===================================================================
--- ZODB/trunk/src/transaction/_transaction.py	2006-01-05 20:48:01 UTC (rev 41163)
+++ ZODB/trunk/src/transaction/_transaction.py	2006-01-05 21:12:02 UTC (rev 41164)
@@ -115,6 +115,20 @@
 passing it a callable and arguments.  The callable will be called with its
 arguments at the start of the commit (but not for substransaction commits).
 
+After-commit hook
+------------------
+
+Sometimes, applications want to execute code after a transaction is
+committed or aborted. For example, one might want to launch non
+transactional code after a successful commit. Or still someone might
+want to launch asynchronous code after.  A post-commit hook is
+available for such use cases: use addAfterCommitHook(), passing it a
+callable and arguments.  The callable will be called with a Boolean
+value representing the status of the commit operation as first
+argument (true if successfull or false iff aborted) preceding its
+arguments at the start of the commit (but not for substransaction
+commits).
+
 Error handling
 --------------
 
@@ -241,6 +255,9 @@
         # List of (hook, args, kws) tuples added by addBeforeCommitHook().
         self._before_commit = []
 
+        # List of (hook, args, kws) tuples added by addAfterCommitHook().
+        self._after_commit = []
+
     # Raise TransactionFailedError, due to commit()/join()/register()
     # getting called when the current transaction has already suffered
     # a commit/savepoint failure.
@@ -292,7 +309,7 @@
             savepoint = Savepoint(self, optimistic, *self._resources)
         except:
             self._cleanup(self._resources)
-            self._saveCommitishError() # reraises!
+            self._saveAndRaiseCommitishError() # reraises!
 
         if self._savepoint2index is None:
             self._savepoint2index = weakref.WeakKeyDictionary()
@@ -376,16 +393,19 @@
 
         try:
             self._commitResources()
+            self.status = Status.COMMITTED
         except:
-            self._saveCommitishError() # This raises!
-
-        self.status = Status.COMMITTED
-        if self._manager:
-            self._manager.free(self)
-        self._synchronizers.map(lambda s: s.afterCompletion(self))
+            t, v, tb = self._saveAndGetCommitishError()
+            self._callAfterCommitHooks(status=False)
+            raise t, v, tb
+        else:
+            if self._manager:
+                self._manager.free(self)
+            self._synchronizers.map(lambda s: s.afterCompletion(self))
+            self._callAfterCommitHooks(status=True)
         self.log.debug("commit")
 
-    def _saveCommitishError(self):
+    def _saveAndGetCommitishError(self):
         self.status = Status.COMMITFAILED
         # Save the traceback for TransactionFailedError.
         ft = self._failure_traceback = StringIO()
@@ -396,6 +416,10 @@
         traceback.print_tb(tb, None, ft)
         # Append the exception type and value.
         ft.writelines(traceback.format_exception_only(t, v))
+        return t, v, tb
+
+    def _saveAndRaiseCommitishError(self):
+        t, v, tb = self._saveAndGetCommitishError()
         raise t, v, tb
 
     def getBeforeCommitHooks(self):
@@ -421,6 +445,44 @@
             hook(*args, **kws)
         self._before_commit = []
 
+    def getAfterCommitHooks(self):
+        return iter(self._after_commit)
+
+    def addAfterCommitHook(self, hook, args=(), kws=None):
+        if kws is None:
+            kws = {}
+        self._after_commit.append((hook, tuple(args), kws))
+
+    def _callAfterCommitHooks(self, status=True):
+        # Avoid to abort anything at the end if no hooks are registred.
+        if not self._after_commit:
+            return
+        # Call all hooks registered, allowing further registrations
+        # during processing.  Note that calls to addAterCommitHook() may
+        # add additional hooks while hooks are running, and iterating over a
+        # growing list is well-defined in Python.
+        for hook, args, kws in self._after_commit:
+            # The first argument passed to the hook is a Boolean value,
+            # true if the commit succeeded, or false if the commit aborted.
+            try:
+                hook(status, *args, **kws)
+            except:
+                # We need to catch the exceptions if we want all hooks
+                # to be called
+                self.log.error("Error in after commit hook exec in %s ",
+                               hook, exc_info=sys.exc_info())
+        # The transaction is already committed. It must not have
+        # further effects after the commit.
+        for rm in self._resources:
+            try:
+                rm.abort(self)
+            except:
+                # XXX should we take further actions here ?
+                self.log.error("Error in abort() on manager %s",
+                               rm, exc_info=sys.exc_info())
+        self._after_commit = []
+        self._before_commit = []
+
     def _commitResources(self):
         # Execute the two-phase commit protocol.
 
@@ -687,7 +749,7 @@
                 savepoint.rollback()
         except:
             # Mark the transaction as failed.
-            transaction._saveCommitishError() # reraises!
+            transaction._saveAndRaiseCommitishError() # reraises!
 
 class AbortSavepoint:
 

Modified: ZODB/trunk/src/transaction/interfaces.py
===================================================================
--- ZODB/trunk/src/transaction/interfaces.py	2006-01-05 20:48:01 UTC (rev 41163)
+++ ZODB/trunk/src/transaction/interfaces.py	2006-01-05 21:12:02 UTC (rev 41164)
@@ -232,6 +232,43 @@
         by a top-level transaction commit.
         """
 
+    def addAfterCommitHook(hook, args=(), kws=None):
+         """Register a hook to call after a transaction commit attempt.
+         
+         The specified hook function will be called after the transaction
+         commit succeeds or aborts.  The first argument passed to the hook
+         is a Boolean value, true if the commit succeeded, or false if the
+         commit aborted.  `args` specifies additional positional, and `kws`
+         keyword, arguments to pass to the hook.  `args` is a sequence of
+         positional arguments to be passed, defaulting to an empty tuple
+         (only the true/false success argument is passed).  `kws` is a
+         dictionary of keyword argument names and values to be passed, or
+         the default None (no keyword arguments are passed).
+         
+         Multiple hooks can be registered and will be called in the order they
+         were registered (first registered, first called).  This method can
+         also be called from a hook:  an executing hook can register more
+         hooks.  Applications should take care to avoid creating infinite loops
+         by recursively registering hooks.
+         
+         Hooks are called only for a top-level commit.  A subtransaction
+         commit or savepoint creation does not call any hooks.  Calling a
+         hook "consumes" its registration:  hook registrations do not
+         persist across transactions.  If it's desired to call the same
+         hook on every transaction commit, then addAfterCommitHook() must be
+         called with that hook during every transaction; in such a case
+         consider registering a synchronizer object via a TransactionManager's
+         registerSynch() method instead.
+         """
+
+    def getAfterCommitHooks():
+        """Return iterable producing the registered addAfterCommit hooks.
+
+        A triple (hook, args, kws) is produced for each registered hook.
+        The hooks are produced in the order in which they would be invoked
+        by a top-level transaction commit.
+        """
+
 class ITransactionDeprecated(zope.interface.Interface):
     """Deprecated parts of the transaction API."""
 

Modified: ZODB/trunk/src/transaction/tests/test_transaction.py
===================================================================
--- ZODB/trunk/src/transaction/tests/test_transaction.py	2006-01-05 20:48:01 UTC (rev 41163)
+++ ZODB/trunk/src/transaction/tests/test_transaction.py	2006-01-05 21:12:02 UTC (rev 41164)
@@ -1,6 +1,6 @@
 ##############################################################################
 #
-# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# Copyright (c) 2001, 2002, 2005 Zope Corporation and Contributors.
 # All Rights Reserved.
 #
 # This software is subject to the provisions of the Zope Public License,
@@ -11,7 +11,7 @@
 # FOR A PARTICULAR PURPOSE
 #
 ##############################################################################
-"""Test tranasction behavior for variety of cases.
+"""Test transaction behavior for variety of cases.
 
 I wrote these unittests to investigate some odd transaction
 behavior when doing unittests of integrating non sub transaction
@@ -241,7 +241,6 @@
 
         assert self.nosub1._p_jar.ctpc_abort == 1
 
-
     # last test, check the hosing mechanism
 
 ##    def testHoserStoppage(self):
@@ -728,8 +727,270 @@
                "arg '-' kw1 'no_kw1' kw2 'no_kw2'",
        'rec0']
       >>> reset_log()
+
+    When modifing persitent objects within before commit hooks
+    modifies the objects, of course :)
+    
+    Start a new transaction
+
+      >>> t = transaction.begin()
+
+    Create a DB instance and add a IOBTree within
+
+      >>> from ZODB.tests.util import DB
+      >>> from ZODB.tests.util import P
+      >>> db = DB()
+      >>> con = db.open()
+      >>> root = con.root()
+      >>> root['p'] = P('julien')
+      >>> p = root['p']
+
+      >>> p.name
+      'julien'
+      
+    This hook will get the object from the `DB` instance and change
+    the flag attribute.
+
+      >>> def hookmodify(status, arg=None, kw1='no_kw1', kw2='no_kw2'):
+      ...     p.name = 'jul'
+
+    Now register this hook and commit.
+
+      >>> t.addBeforeCommitHook(hookmodify, (p, 1))
+      >>> transaction.commit()
+
+    Nothing should have changed since it should have been aborted.
+
+      >>> p.name
+      'jul'
+
+      >>> db.close()
     """
 
+def test_addAfterCommitHook():
+    """Test addAfterCommitHook.
+
+    Let's define a hook to call, and a way to see that it was called.
+
+      >>> log = []
+      >>> def reset_log():
+      ...     del log[:]
+
+      >>> def hook(status, arg='no_arg', kw1='no_kw1', kw2='no_kw2'):
+      ...     log.append("%r arg %r kw1 %r kw2 %r" % (status, arg, kw1, kw2))
+
+    Now register the hook with a transaction.
+
+      >>> import transaction
+      >>> t = transaction.begin()
+      >>> t.addAfterCommitHook(hook, '1')
+
+    We can see that the hook is indeed registered.
+
+      >>> [(hook.func_name, args, kws)
+      ...  for hook, args, kws in t.getAfterCommitHooks()]
+      [('hook', ('1',), {})]
+
+    When transaction commit is done, the hook is called, with its
+    arguments.
+
+      >>> log
+      []
+      >>> t.commit()
+      >>> log
+      ["True arg '1' kw1 'no_kw1' kw2 'no_kw2'"]
+      >>> reset_log()
+
+    A hook's registration is consumed whenever the hook is called.  Since
+    the hook above was called, it's no longer registered:
+
+      >>> len(list(t.getAfterCommitHooks()))
+      0
+      >>> transaction.commit()
+      >>> log
+      []
+
+    The hook is only called after a full commit, not for a savepoint or
+    subtransaction.
+
+      >>> t = transaction.begin()
+      >>> t.addAfterCommitHook(hook, 'A', dict(kw1='B'))
+      >>> dummy = t.savepoint()
+      >>> log
+      []
+      >>> t.commit(subtransaction=True)
+      >>> log
+      []
+      >>> t.commit()
+      >>> log
+      ["True arg 'A' kw1 'B' kw2 'no_kw2'"]
+      >>> reset_log()
+
+    If a transaction is aborted, no hook is called.
+
+      >>> t = transaction.begin()
+      >>> t.addAfterCommitHook(hook, ["OOPS!"])
+      >>> transaction.abort()
+      >>> log
+      []
+      >>> transaction.commit()
+      >>> log
+      []
+
+    The hook is called after the commit is done, so even if the
+    commit fails the hook will have been called.  To provoke failures in
+    commit, we'll add failing resource manager to the transaction.
+
+      >>> class CommitFailure(Exception):
+      ...     pass
+      >>> class FailingDataManager:
+      ...     def tpc_begin(self, txn, sub=False):
+      ...         raise CommitFailure
+      ...     def abort(self, txn):
+      ...         pass
+
+      >>> t = transaction.begin()
+      >>> t.join(FailingDataManager())
+
+      >>> t.addAfterCommitHook(hook, '2')
+      >>> t.commit()
+      Traceback (most recent call last):
+      ...
+      CommitFailure
+      >>> log
+      ["False arg '2' kw1 'no_kw1' kw2 'no_kw2'"]
+      >>> reset_log()
+
+    Let's register several hooks.
+
+      >>> t = transaction.begin()
+      >>> t.addAfterCommitHook(hook, '4', dict(kw1='4.1'))
+      >>> t.addAfterCommitHook(hook, '5', dict(kw2='5.2'))
+
+    They are returned in the same order by getAfterCommitHooks.
+
+      >>> [(hook.func_name, args, kws)     #doctest: +NORMALIZE_WHITESPACE
+      ...  for hook, args, kws in t.getAfterCommitHooks()]
+      [('hook', ('4',), {'kw1': '4.1'}),
+       ('hook', ('5',), {'kw2': '5.2'})]
+
+    And commit also calls them in this order.
+
+      >>> t.commit()
+      >>> len(log)
+      2
+      >>> log  #doctest: +NORMALIZE_WHITESPACE
+      ["True arg '4' kw1 '4.1' kw2 'no_kw2'",
+       "True arg '5' kw1 'no_kw1' kw2 '5.2'"]
+      >>> reset_log()
+
+    While executing, a hook can itself add more hooks, and they will all
+    be called before the real commit starts.
+
+      >>> def recurse(status, txn, arg):
+      ...     log.append('rec' + str(arg))
+      ...     if arg:
+      ...         txn.addAfterCommitHook(hook, '-')
+      ...         txn.addAfterCommitHook(recurse, (txn, arg-1))
+
+      >>> t = transaction.begin()
+      >>> t.addAfterCommitHook(recurse, (t, 3))
+      >>> transaction.commit()
+      >>> log  #doctest: +NORMALIZE_WHITESPACE
+      ['rec3',
+               "True arg '-' kw1 'no_kw1' kw2 'no_kw2'",
+       'rec2',
+               "True arg '-' kw1 'no_kw1' kw2 'no_kw2'",
+       'rec1',
+               "True arg '-' kw1 'no_kw1' kw2 'no_kw2'",
+       'rec0']
+      >>> reset_log()
+
+    If an after commit hook is raising an exception then it will log a
+    message at error level so that if other hooks are registered they
+    can be executed. We don't support execution dependencies at this level.
+
+      >>> mgr = transaction.TransactionManager()
+      >>> do = DataObject(mgr)
+
+      >>> def hookRaise(status, arg='no_arg', kw1='no_kw1', kw2='no_kw2'):
+      ...     raise TypeError("Fake raise")
+
+      >>> t = transaction.begin()
+
+      >>> t.addAfterCommitHook(hook, ('-', 1))
+      >>> t.addAfterCommitHook(hookRaise, ('-', 2))
+      >>> t.addAfterCommitHook(hook, ('-', 3))
+      >>> transaction.commit()
+
+      >>> log
+      ["True arg '-' kw1 1 kw2 'no_kw2'", "True arg '-' kw1 3 kw2 'no_kw2'"]
+
+      >>> reset_log()
+
+    Test that the associated transaction manager has been cleanup when
+    after commit hooks are registered
+
+      >>> mgr = transaction.TransactionManager()
+      >>> do = DataObject(mgr)
+
+      >>> t = transaction.begin()
+      >>> len(t._manager._txns)
+      1
+
+      >>> t.addAfterCommitHook(hook, ('-', 1))
+      >>> transaction.commit()
+
+      >>> log
+      ["True arg '-' kw1 1 kw2 'no_kw2'"]
+
+      >>> len(t._manager._txns)
+      0
+
+      >>> reset_log()
+
+
+    The transaction is already committed when the after commit hooks
+    will be executed. Executing the hooks must not have further
+    effects on persistent objects.
+
+    Start a new transaction
+
+      >>> t = transaction.begin()
+
+    Create a DB instance and add a IOBTree within
+
+      >>> from ZODB.tests.util import DB
+      >>> from ZODB.tests.util import P
+      >>> db = DB()
+      >>> con = db.open()
+      >>> root = con.root()
+      >>> root['p'] = P('julien')
+      >>> p = root['p']
+
+      >>> p.name
+      'julien'
+      
+    This hook will get the object from the `DB` instance and change
+    the flag attribute.
+
+      >>> def badhook(status, arg=None, kw1='no_kw1', kw2='no_kw2'):
+      ...     p.name = 'jul'
+
+    Now register this hook and commit.
+
+      >>> t.addAfterCommitHook(badhook, (p, 1))
+      >>> transaction.commit()
+
+    Nothing should have changed since it should have been aborted.
+
+      >>> p.name
+      'julien'
+
+      >>> db.close()
+
+    """
+
 def test_suite():
     from zope.testing.doctest import DocTestSuite
     return unittest.TestSuite((



More information about the Zodb-checkins mailing list