@@ -339,25 +339,23 @@ class Transaction:
339339 self._on_exit.clear()
340340 return self._stack.__exit__(exc_type, exc, tb)
341341
342- def on_failure(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None:
343- """
344- Register a failure callback (runs if the with - block exits without commit()).
345- Values are bound now via partial so late mutations don' t bite you.
346- """
342+ def _register(self, callback: Callable[[], Any], on_commit: bool) -> None:
347343 if not self._entered:
348- raise RuntimeError("Transaction must be entered before on_failure()")
349- self._stack.callback(partial(fn, *args, **kwargs))
344+ raise RuntimeError("Transaction must be entered before registering callbacks")
345+ # The ExitStack copy runs on rollback (LIFO, interleaved with the failure
346+ # callbacks); the _on_exit copy runs at commit(). commit() disarms the stack
347+ # before running _on_exit, so exactly one of the two ever fires.
348+ self._stack.callback(callback)
349+ if on_commit:
350+ self._on_exit.append(callback)
351+
352+ def on_failure(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None:
353+ """ Register a failure callback (runs if the with - block exits without commit())."""
354+ self._register(partial(fn, *args, **kwargs), on_commit=False)
350355
351356 def on_exit(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None:
352- """
353- Register an exit callback (runs exactly once, on rollback or during commit()).
354- Values are bound now via partial so late mutations don' t bite you.
355- """
356- if not self._entered:
357- raise RuntimeError("Transaction must be entered before on_exit()")
358- callback = partial(fn, *args, **kwargs)
359- self._stack.callback(callback)
360- self._on_exit.append(callback)
357+ """ Register an exit callback (runs exactly once, on rollback or during commit())."""
358+ self._register(partial(fn, *args, **kwargs), on_commit=True)
361359
362360 def commit(self) -> None:
363361 """
0 commit comments