Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support abort hooks (symmetrically to commit hooks) #77 #81

Merged
merged 8 commits into from
Jun 19, 2019
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
if it is only an instance of `BaseException` but not of `Exception`,
such as e.g. a ``SystemExit`` or ``KeyboardInterupt`` exception.

- Support abort hooks (symmetrically to commit hooks)
(`#77 <https://github.com/zopefoundation/transaction/issues/77>`_).


2.4.0 (2018-10-23)
==================
Expand Down
70 changes: 70 additions & 0 deletions transaction/_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ def __init__(self, synchronizers=None, manager=None):
# List of (hook, args, kws) tuples added by addAfterCommitHook().
self._after_commit = []


d-maurer marked this conversation as resolved.
Show resolved Hide resolved
# List of (hook, args, kws) tuples added by addBeforeAbortHook().
self._before_abort = []

# List of (hook, args, kws) tuples added by addAfterAbortHook().
self._after_abort = []

@property
def _extension(self):
# for backward compatibility, since most clients used this
Expand Down Expand Up @@ -409,6 +416,67 @@ def _callAfterCommitHooks(self, status=True):
self._after_commit = []
self._before_commit = []

def getBeforeAbortHooks(self):
d-maurer marked this conversation as resolved.
Show resolved Hide resolved
""" See ITransaction.
"""
return iter(self._before_abort)

def addBeforeAbortHook(self, hook, args=(), kws=None):
""" See ITransaction.
"""
if kws is None:
kws = {}
self._before_abort.append((hook, tuple(args), kws))

def _callBeforeAbortHooks(self):
# Call all hooks registered, allowing further registrations
# during processing. Note that calls to addBeforeAbortHook() 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._before_abort:
d-maurer marked this conversation as resolved.
Show resolved Hide resolved
hook(*args, **kws)
self._before_abort = []

def getAfterAbortHooks(self):
""" See ITransaction.
"""
return iter(self._after_abort)

def addAfterAbortHook(self, hook, args=(), kws=None):
""" See ITransaction.
"""
if kws is None:
kws = {}
self._after_abort.append((hook, tuple(args), kws))

def _callAfterAbortHooks(self):
# Avoid to abort anything at the end if no hooks are registred.
if not self._after_abort:
return
# Call all hooks registered, allowing further registrations
# during processing. Note that calls to addAterAbortHook() 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_abort:
try:
hook(*args, **kws)
except:
# We need to catch the exceptions if we want all hooks
# to be called
self.log.error("Error in after abort hook exec in %s ",
hook, exc_info=sys.exc_info())
# The transaction is already abortted. It must not have
d-maurer marked this conversation as resolved.
Show resolved Hide resolved
# further effects after the abort.
for rm in self._resources:
try:
rm.abort(self)
d-maurer marked this conversation as resolved.
Show resolved Hide resolved
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_abort = []
self._before_abort = []

def _commitResources(self):
# Execute the two-phase commit protocol.

Expand Down Expand Up @@ -499,6 +567,7 @@ def set_data(self, ob, ob_data):
def abort(self):
""" See ITransaction.
"""
self._callBeforeAbortHooks()
if self._savepoint2index:
self._invalidate_all_savepoints()

Expand All @@ -519,6 +588,7 @@ def abort(self):
self.log.error("Failed to abort resource manager: %s",
rm, exc_info=sys.exc_info())

self._callAfterAbortHooks()
self._free()

self._synchronizers.map(lambda s: s.afterCompletion(self))
Expand Down
68 changes: 68 additions & 0 deletions transaction/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,74 @@ def getAfterCommitHooks():
by a top-level transaction commit.
"""

def addBeforeAbortHook(hook, args=(), kws=None):
"""Register a hook to call before the transaction is abortted.

The specified hook function will be called after the transaction's
abort method has been called, but before the abort process has been
started. The hook will be passed the specified positional (`args`)
and keyword (`kws`) arguments. `args` is a sequence of positional
arguments to be passed, defaulting to an empty tuple (no positional
arguments are 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 abort. If the
d-maurer marked this conversation as resolved.
Show resolved Hide resolved
transaction is committed, abort hooks are not called.
This is true even if the commit causes
internally an abort; in this case, the after commit hooks
are called with first argument `False`.
Calling a hook "consumes" its registration too: hook registrations
do not persist across transactions.
"""

def getBeforeAbortHooks():
"""Return iterable producing the registered addBeforeAbort 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 abort.
"""

def addAfterAbortHook(hook, args=(), kws=None):
"""Register a hook to call after a transaction abort.

The specified hook function will be called after the transaction
abort with positional arguments `args` and `kws`
keyword arguments. `args` is a sequence of
positional arguments to be passed, defaulting to an empty tuple
`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 abort. If the
transaction is committed, abort hooks are not called.
This is true even if the commit causes
internally an abort; in this case, the after commit hooks
are called with first argument `False`.
Calling a hook "consumes" its registration too: hook registrations
do not persist across transactions.
"""

def getAfterAbortHooks():
"""Return iterable producing the registered addAfterAbort 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 abort.
"""

def set_data(ob, data):
"""Hold data on behalf of an object

Expand Down
147 changes: 147 additions & 0 deletions transaction/tests/test__transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,153 @@ class DM(object):
txn.abort()
self.assertEqual(txn._resources, [])

def test_getBeforeAbortHooks_empty(self):
txn = self._makeOne()
self.assertEqual(list(txn.getBeforeAbortHooks()), [])

def test_addBeforeAbortHook(self):
def _hook(*args, **kw):
raise AssertionError("Not called")
txn = self._makeOne()
txn.addBeforeAbortHook(_hook, ('one',), dict(uno=1))
self.assertEqual(list(txn.getBeforeAbortHooks()),
[(_hook, ('one',), {'uno': 1})])

def test_addBeforeAbortHook_w_kws(self):
def _hook(*args, **kw):
raise AssertionError("Not called")
txn = self._makeOne()
txn.addBeforeAbortHook(_hook, ('one',))
self.assertEqual(list(txn.getBeforeAbortHooks()),
[(_hook, ('one',), {})])

def test_getAfterAbortHooks_empty(self):
txn = self._makeOne()
self.assertEqual(list(txn.getAfterAbortHooks()), [])

def test_addAfterAbortHook(self):
def _hook(*args, **kw):
raise AssertionError("Not called")
txn = self._makeOne()
txn.addAfterAbortHook(_hook, ('one',), dict(uno=1))
self.assertEqual(list(txn.getAfterAbortHooks()),
[(_hook, ('one',), {'uno': 1})])

def test_addAfterAbortHook_wo_kws(self):
def _hook(*args, **kw):
raise AssertionError("Not called")
txn = self._makeOne()
txn.addAfterAbortHook(_hook, ('one',))
self.assertEqual(list(txn.getAfterAbortHooks()),
[(_hook, ('one',), {})])

def test_callAfterAbortHook_w_error(self):
from transaction.tests.common import DummyLogger
from transaction.tests.common import Monkey
from transaction import _transaction
_hooked2 = []
def _hook1(*args, **kw):
raise ValueError()
def _hook2(*args, **kw):
_hooked2.append((args, kw))
logger = DummyLogger()
with Monkey(_transaction, _LOGGER=logger):
txn = self._makeOne()
logger._clear()
txn.addAfterAbortHook(_hook1, ('one',))
txn.addAfterAbortHook(_hook2, ('two',), dict(dos=2))
txn._callAfterAbortHooks()
# second hook gets called even if first raises
self.assertEqual(_hooked2, [(('two',), {'dos': 2})])
self.assertEqual(len(logger._log), 1)
self.assertEqual(logger._log[0][0], 'error')
self.assertTrue(logger._log[0][1].startswith(
"Error in after abort hook"))

def test_callAfterAbortHook_w_abort(self):
from transaction.tests.common import DummyLogger
from transaction.tests.common import Monkey
from transaction import _transaction
_hooked2 = []
def _hook1(*args, **kw):
raise ValueError()
def _hook2(*args, **kw):
_hooked2.append((args, kw))
logger = DummyLogger()
with Monkey(_transaction, _LOGGER=logger):
txn = self._makeOne()
logger._clear()
txn.addAfterAbortHook(_hook1, ('one',))
txn.addAfterAbortHook(_hook2, ('two',), dict(dos=2))
txn._callAfterAbortHooks()
self.assertEqual(logger._log[0][0], 'error')
self.assertTrue(logger._log[0][1].startswith(
"Error in after abort hook"))

def test_callAfterAbortHook_w_abort_error(self):
from transaction.tests.common import DummyLogger
from transaction.tests.common import Monkey
from transaction import _transaction
_hooked2 = []
def _hook2(*args, **kw):
_hooked2.append((args, kw))
logger = DummyLogger()
with Monkey(_transaction, _LOGGER=logger):
txn = self._makeOne()
logger._clear()
r = Resource("r", "abort")
txn.join(r)
txn.addAfterAbortHook(_hook2, ('two',), dict(dos=2))
txn._callAfterAbortHooks()
self.assertEqual(logger._log[0][0], 'error')
self.assertTrue(logger._log[0][1].startswith(
"Error in abort() on manager"))

def test_abort_w_abortHooks(self):
comm = []
txn = self._makeOne()
def bah():
comm.append("before")
def aah():
comm.append("after")
txn.addAfterAbortHook(aah)
txn.addBeforeAbortHook(bah)
txn.abort()
self.assertEqual(comm, ["before", "after"])
self.assertEqual(list(txn.getBeforeAbortHooks()), [])
self.assertEqual(list(txn.getAfterAbortHooks()), [])

def test_commit_w_abortHooks(self):
comm = []
txn = self._makeOne()
def bah():
comm.append("before") # pragma: no cover
def aah():
comm.append("after") # pragma: no cover
txn.addAfterAbortHook(aah)
txn.addBeforeAbortHook(bah)
txn.commit()
self.assertEqual(comm, []) # not called
self.assertEqual(list(txn.getBeforeAbortHooks()), [(bah, (), {})])
self.assertEqual(list(txn.getAfterAbortHooks()), [(aah, (), {})])

def test_commit_w_error_w_abortHooks(self):
comm = []
txn = self._makeOne()
def bah():
comm.append("before") # pragma: no cover
def aah():
comm.append("after") # pragma: no cover
txn.addAfterAbortHook(aah)
txn.addBeforeAbortHook(bah)
r = Resource("aaa", "tpc_vote")
txn.join(r)
with self.assertRaises(ValueError):
txn.commit()
self.assertEqual(comm, []) # not called
self.assertEqual(list(txn.getBeforeAbortHooks()), [(bah, (), {})])
self.assertEqual(list(txn.getAfterAbortHooks()), [(aah, (), {})])

def test_note(self):
txn = self._makeOne()
try:
Expand Down