This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 77bc9ccf0eaa CAMEL-26742: camel-core - on completion listener SPI for 
the exchange sanitizer (#29436)
77bc9ccf0eaa is described below

commit 77bc9ccf0eaa6fe940b477ae8371b68636d15e32
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 9 15:04:36 2026 +0530

    CAMEL-26742: camel-core - on completion listener SPI for the exchange 
sanitizer (#29436)
    
    * CAMEL-26742: camel-core - on completion listener SPI for the exchange 
sanitizer
    * CAMEL-26742: camel-api - rename the handover method that returns what 
moved to handoverSynchronizationWithResult
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/ExtendedCamelContext.java     |  36 ++
 .../org/apache/camel/spi/OnCompletionListener.java | 143 ++++++
 .../main/java/org/apache/camel/spi/UnitOfWork.java |  26 ++
 .../impl/engine/DefaultCamelContextExtension.java  |  20 +
 .../camel/impl/engine/DefaultUnitOfWork.java       |  35 +-
 .../OnCompletionListenerCustomUnitOfWorkTest.java  | 111 +++++
 .../camel/processor/OnCompletionListenerTest.java  | 492 +++++++++++++++++++++
 .../org/apache/camel/support/AbstractExchange.java |  45 +-
 .../camel/support/OnCompletionListenerHelper.java  | 145 ++++++
 .../org/apache/camel/support/UnitOfWorkHelper.java |  12 +-
 design/exchange-sanitizer.adoc                     |  20 +-
 11 files changed, 1071 insertions(+), 14 deletions(-)

diff --git 
a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java 
b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
index c7c3bd577c07..96a4adb29f47 100644
--- a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
+++ b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
@@ -38,6 +38,7 @@ import org.apache.camel.spi.LifecycleStrategy;
 import org.apache.camel.spi.LogListener;
 import org.apache.camel.spi.ManagementMBeanAssembler;
 import org.apache.camel.spi.NormalizedEndpointUri;
+import org.apache.camel.spi.OnCompletionListener;
 import org.apache.camel.spi.ProcessorExchangeFactory;
 import org.apache.camel.spi.ReactiveExecutor;
 import org.apache.camel.spi.Registry;
@@ -602,6 +603,41 @@ public interface ExtendedCamelContext {
         return Collections.emptyList();
     }
 
+    /**
+     * Adds an {@link OnCompletionListener} that observes where the on 
completions of the exchanges are: when they move
+     * into a unit of work, are added to or removed from one, are handed over 
or taken out, and when they run. Many
+     * listeners can be installed; adding a listener that is already installed 
(equal to one installed) is ignored. The
+     * listeners are read at each event, so a listener added while the routes 
run is called from the next event on. The
+     * CamelContext does not start or stop the listeners, and they stay 
installed when the CamelContext is stopped and
+     * started again.
+     *
+     * @param listener the listener
+     * @since          4.24
+     */
+    default void addOnCompletionListener(OnCompletionListener listener) {
+        throw new UnsupportedOperationException("This CamelContext does not 
support an OnCompletionListener");
+    }
+
+    /**
+     * Removes an {@link OnCompletionListener} (one equal to it). It is no 
longer called from the next event on.
+     *
+     * @param listener the listener
+     * @since          4.24
+     */
+    default void removeOnCompletionListener(OnCompletionListener listener) {
+        // no listener can be installed, so there is nothing to remove
+    }
+
+    /**
+     * Gets the {@link OnCompletionListener}s, in the order they were added.
+     *
+     * @return an unmodifiable view of the listeners (empty if none is 
installed), which reflects later adds and removes
+     * @since  4.24
+     */
+    default List<OnCompletionListener> getOnCompletionListeners() {
+        return Collections.emptyList();
+    }
+
     /**
      * Setup management according to whether JMX is enabled or disabled.
      *
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/OnCompletionListener.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/OnCompletionListener.java
new file mode 100644
index 000000000000..d0e5eee508e2
--- /dev/null
+++ 
b/core/camel-api/src/main/java/org/apache/camel/spi/OnCompletionListener.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.spi;
+
+import java.util.List;
+
+import org.apache.camel.Exchange;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A listener that observes where the on completions ({@link 
Synchronization}s) of the exchanges are: when an exchange
+ * gets a unit of work and its on completions move into it, when an on 
completion is added to or removed from a unit of
+ * work, handed over to another exchange or taken out to be run by someone 
else, and when it runs. A listener only
+ * observes: it must not change the exchanges, the units of work or the lists 
it is given, and what it throws cannot
+ * change the routing. It is meant for development and test tools that check 
the contract of the commit of a consumer,
+ * described on {@link Synchronization}.
+ * <p/>
+ * Listeners are installed with
+ * {@link 
org.apache.camel.ExtendedCamelContext#addOnCompletionListener(OnCompletionListener)}.
 Many can be installed.
+ * They are called in the order they were added, and must not depend on that 
order. Unlike the {@link StepListener},
+ * they are read at each event, so a listener added or removed while the 
routes run is called, or no longer called, from
+ * the next event on. With no listener installed, each of these places only 
checks that the list of listeners is empty.
+ * <p/>
+ * <b>Where it is called.</b>
+ * <ul>
+ * <li>{@link #onUnitOfWork(Exchange, UnitOfWork, List)} when an exchange is 
given a unit of work (also one it shares
+ * with a parent exchange), before the on completions it held until then move 
into it.</li>
+ * <li>{@link #onCompletionAdded(UnitOfWork, Synchronization)} when an on 
completion is added to a unit of work of Camel
+ * ({@link UnitOfWork#addSynchronization(Synchronization)}), also as part of 
such a move or of a handover. The adds of a
+ * move come after the {@link #onUnitOfWork(Exchange, UnitOfWork, List)} of 
the move, and the adds of a handover before
+ * the {@link #onCompletionHandover(Exchange, UnitOfWork, Exchange, List)} of 
the handover, on the same thread, so a
+ * listener can tell them apart. Adds to an exchange that has no unit of work 
yet are not reported: they show up in the
+ * move.</li>
+ * <li>{@link #onCompletionRemoved(UnitOfWork, Synchronization)} after an on 
completion is removed from a unit of work
+ * of Camel ({@link UnitOfWork#removeSynchronization(Synchronization)}), so it 
does not run.</li>
+ * <li>{@link #onCompletionHandover(Exchange, UnitOfWork, Exchange, List)} 
after on completions are handed over from one
+ * exchange to another ({@link 
org.apache.camel.ExchangeExtension#handoverCompletions(Exchange)}), such as to 
a copy
+ * that is routed on its own, or back from a copy that is not routed.</li>
+ * <li>{@link #onCompletionTaken(Exchange, List)} after on completions are 
taken out of an exchange as a list
+ * ({@link org.apache.camel.ExchangeExtension#handoverCompletions()}), to be 
run by someone else.</li>
+ * <li>{@link #onCompletionRun(Exchange, Synchronization, boolean)} before an 
on completion runs (when a unit of work is
+ * done, or when a list of on completions that was taken out is run).</li>
+ * </ul>
+ * Exchange created, completed and failed are events of the {@link 
EventNotifier}, so they are not in this listener.
+ * <p/>
+ * <b>Threads.</b> The listeners are called on the thread that makes the 
change, by many threads at the same time: they
+ * must be thread-safe and quick. The order above holds for the events of one 
thread; events of different threads (such
+ * as the parts of a parallel split that share a unit of work) are not ordered 
with each other. A listener may read the
+ * exchanges it is given on that thread. It should not keep the exchanges or 
the on completions, so these can be
+ * released when the exchange is done. The lists it is given are unmodifiable, 
and valid during the call only.
+ * <p/>
+ * An exchange that is created as a copy of another ({@link Exchange#copy()}) 
starts with the unit of work of that
+ * exchange, without {@link #onUnitOfWork(Exchange, UnitOfWork, List)}.
+ * <p/>
+ * <b>Failures.</b> What a listener throws cannot change the routing. It is 
caught and logged at WARN once per listener
+ * (then at DEBUG), and the change or the run of the on completion happens as 
without the listener.
+ * <p/>
+ * All methods have an empty default.
+ *
+ * @since 4.24
+ */
+public interface OnCompletionListener {
+
+    /**
+     * An exchange is given a unit of work. The on completions it held until 
then move into it after this call.
+     *
+     * @param exchange   the exchange
+     * @param unitOfWork the unit of work
+     * @param moved      the on completions that move into the unit of work 
(empty if none)
+     */
+    default void onUnitOfWork(Exchange exchange, UnitOfWork unitOfWork, 
List<Synchronization> moved) {
+    }
+
+    /**
+     * An on completion is added to a unit of work.
+     *
+     * @param unitOfWork   the unit of work
+     * @param onCompletion the on completion
+     */
+    default void onCompletionAdded(UnitOfWork unitOfWork, Synchronization 
onCompletion) {
+    }
+
+    /**
+     * An on completion was removed from a unit of work, so it does not run. 
Called only when it was there.
+     *
+     * @param unitOfWork   the unit of work
+     * @param onCompletion the on completion
+     */
+    default void onCompletionRemoved(UnitOfWork unitOfWork, Synchronization 
onCompletion) {
+    }
+
+    /**
+     * On completions were handed over from one exchange (and its unit of 
work, if it has one) to another exchange.
+     * Called only when on completions moved, or when the unit of work does 
not tell what moved.
+     *
+     * @param from           the exchange that handed them over
+     * @param fromUnitOfWork the unit of work of that exchange they were 
handed over from, or {@code null} when the
+     *                       exchange held them itself (it had no unit of work)
+     * @param to             the exchange they were handed over to
+     * @param moved          the on completions that moved (not empty), or 
{@code null} when the unit of work does not
+     *                       tell (a custom {@link UnitOfWork} that does not 
implement
+     *                       {@link 
UnitOfWork#handoverSynchronizationWithResult(Exchange, 
java.util.function.Predicate)})
+     */
+    default void onCompletionHandover(
+            Exchange from, @Nullable UnitOfWork fromUnitOfWork, Exchange to, 
@Nullable List<Synchronization> moved) {
+    }
+
+    /**
+     * On completions were taken out of an exchange as a list, to be run by 
someone else.
+     *
+     * @param from  the exchange they were taken out of
+     * @param taken the on completions (not empty)
+     */
+    default void onCompletionTaken(Exchange from, List<Synchronization> taken) 
{
+    }
+
+    /**
+     * An on completion is about to run {@link 
Synchronization#onComplete(Exchange)} or
+     * {@link Synchronization#onFailure(Exchange)}.
+     *
+     * @param exchange     the exchange it runs with
+     * @param onCompletion the on completion
+     * @param failure      {@code true} if it runs {@link 
Synchronization#onFailure(Exchange)}, {@code false} for
+     *                     {@link Synchronization#onComplete(Exchange)}
+     */
+    default void onCompletionRun(Exchange exchange, Synchronization 
onCompletion, boolean failure) {
+    }
+
+}
diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/UnitOfWork.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/UnitOfWork.java
index 1204c1c71609..eb79d0400844 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/UnitOfWork.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/UnitOfWork.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.spi;
 
+import java.util.List;
 import java.util.function.Predicate;
 
 import org.apache.camel.AsyncCallback;
@@ -124,6 +125,31 @@ public interface UnitOfWork {
      */
     void handoverSynchronization(Exchange target, @Nullable 
Predicate<Synchronization> filter);
 
+    /**
+     * Handover the registered synchronizations to the target {@link 
org.apache.camel.Exchange}, as
+     * {@link #handoverSynchronization(Exchange, Predicate)} does, and tells 
which ones were handed over (a
+     * {@link SynchronizationVetoable} can refuse to be handed over). Camel 
calls it instead of
+     * {@link #handoverSynchronization(Exchange, Predicate)} when an {@link 
OnCompletionListener} is installed.
+     * <p/>
+     * The default hands over with {@link #handoverSynchronization(Exchange)} 
or
+     * {@link #handoverSynchronization(Exchange, Predicate)}, as Camel does 
without a listener, and returns
+     * {@code null}, as it does not know what was handed over; so a custom 
{@link UnitOfWork} hands over as before.
+     *
+     * @param  target the target exchange
+     * @param  filter optional filter to only handover if filter returns 
<tt>true</tt>
+     * @return        the synchronizations that were handed over (empty if 
none), or {@code null} if not known
+     * @since         4.24
+     */
+    default @Nullable List<Synchronization> handoverSynchronizationWithResult(
+            Exchange target, @Nullable Predicate<Synchronization> filter) {
+        if (filter == null) {
+            handoverSynchronization(target);
+        } else {
+            handoverSynchronization(target, filter);
+        }
+        return null;
+    }
+
     /**
      * Invoked when this unit of work has been completed, whether it has 
failed or completed
      *
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
index 25fafec3d447..01a79fd3590f 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
@@ -66,6 +66,7 @@ import org.apache.camel.spi.ManagementStrategyFactory;
 import org.apache.camel.spi.MessageHistoryFactory;
 import org.apache.camel.spi.MessageSizeStrategy;
 import org.apache.camel.spi.NormalizedEndpointUri;
+import org.apache.camel.spi.OnCompletionListener;
 import org.apache.camel.spi.PluginManager;
 import org.apache.camel.spi.ProcessorExchangeFactory;
 import org.apache.camel.spi.PropertiesComponent;
@@ -113,6 +114,8 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
     private final Set<LogListener> logListeners = new LinkedHashSet<>();
     private final CopyOnWriteArrayList<StepListener> stepListeners = new 
CopyOnWriteArrayList<>();
     private final List<StepListener> stepListenersView = 
Collections.unmodifiableList(stepListeners);
+    private final CopyOnWriteArrayList<OnCompletionListener> 
onCompletionListeners = new CopyOnWriteArrayList<>();
+    private final List<OnCompletionListener> onCompletionListenersView = 
Collections.unmodifiableList(onCompletionListeners);
     private final PluginManager pluginManager = new 
DefaultContextPluginManager();
     private final RouteController internalRouteController;
 
@@ -604,6 +607,23 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
         return stepListenersView;
     }
 
+    @Override
+    public void addOnCompletionListener(OnCompletionListener listener) {
+        ObjectHelper.notNull(listener, "listener");
+        // a listener equal to one already installed is ignored
+        onCompletionListeners.addIfAbsent(listener);
+    }
+
+    @Override
+    public void removeOnCompletionListener(OnCompletionListener listener) {
+        onCompletionListeners.remove(listener);
+    }
+
+    @Override
+    public List<OnCompletionListener> getOnCompletionListeners() {
+        return onCompletionListenersView;
+    }
+
     @Override
     public Set<LogListener> getLogListeners() {
         return logListeners;
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultUnitOfWork.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultUnitOfWork.java
index 87a72d721587..6579055ffc66 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultUnitOfWork.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultUnitOfWork.java
@@ -35,11 +35,13 @@ import org.apache.camel.Processor;
 import org.apache.camel.Route;
 import org.apache.camel.StreamCache;
 import org.apache.camel.spi.InflightRepository;
+import org.apache.camel.spi.OnCompletionListener;
 import org.apache.camel.spi.StreamCachingStrategy;
 import org.apache.camel.spi.Synchronization;
 import org.apache.camel.spi.SynchronizationVetoable;
 import org.apache.camel.spi.UnitOfWork;
 import org.apache.camel.support.EventHelper;
+import org.apache.camel.support.OnCompletionListenerHelper;
 import org.apache.camel.support.UnitOfWorkHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -200,18 +202,29 @@ public class DefaultUnitOfWork implements UnitOfWork {
         } finally {
             lock.unlock();
         }
+        final List<OnCompletionListener> listeners = 
OnCompletionListenerHelper.getListeners(context);
+        if (!listeners.isEmpty()) {
+            OnCompletionListenerHelper.onCompletionAdded(listeners, this, 
synchronization);
+        }
     }
 
     @Override
     public void removeSynchronization(Synchronization synchronization) {
+        boolean removed = false;
         lock.lock();
         try {
             if (synchronizations != null) {
-                synchronizations.remove(synchronization);
+                removed = synchronizations.remove(synchronization);
             }
         } finally {
             lock.unlock();
         }
+        if (removed) {
+            final List<OnCompletionListener> listeners = 
OnCompletionListenerHelper.getListeners(context);
+            if (!listeners.isEmpty()) {
+                OnCompletionListenerHelper.onCompletionRemoved(listeners, 
this, synchronization);
+            }
+        }
     }
 
     @Override
@@ -231,6 +244,23 @@ public class DefaultUnitOfWork implements UnitOfWork {
 
     @Override
     public void handoverSynchronization(Exchange target, 
Predicate<Synchronization> filter) {
+        doHandoverSynchronization(target, filter, null);
+    }
+
+    /**
+     * Hands over as {@link #handoverSynchronization(Exchange, Predicate)} 
does, and returns what was handed over. A
+     * subclass that changes how the synchronizations are handed over should 
override this method too, as Camel calls it
+     * instead of the others when an {@link OnCompletionListener} is installed.
+     */
+    @Override
+    public List<Synchronization> handoverSynchronizationWithResult(Exchange 
target, Predicate<Synchronization> filter) {
+        final List<Synchronization> moved = new ArrayList<>(2);
+        doHandoverSynchronization(target, filter, moved);
+        return moved;
+    }
+
+    private void doHandoverSynchronization(
+            Exchange target, Predicate<Synchronization> filter, 
List<Synchronization> moved) {
         // the parts of a parallel Split, Multicast or Recipient List with 
shareUnitOfWork use this unit of work at the
         // same time, so work on a copy, and take each synchronization out 
under the lock before handing it over, so it
         // is handed over only once (and the target is not called while 
holding the lock)
@@ -252,6 +282,9 @@ public class DefaultUnitOfWork implements UnitOfWork {
                 if (takeSynchronization(synchronization)) {
                     log.trace("Handover synchronization {} to: {}", 
synchronization, target);
                     
target.getExchangeExtension().addOnCompletion(synchronization);
+                    if (moved != null) {
+                        moved.add(synchronization);
+                    }
                     // Allow the synchronization to do housekeeping before 
transfer
                     if (veto != null) {
                         veto.beforeHandover(target);
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/OnCompletionListenerCustomUnitOfWorkTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/OnCompletionListenerCustomUnitOfWorkTest.java
new file mode 100644
index 000000000000..97159740fbbd
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/OnCompletionListenerCustomUnitOfWorkTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.processor;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.engine.DefaultUnitOfWork;
+import org.apache.camel.processor.OnCompletionListenerTest.Commit;
+import org.apache.camel.processor.OnCompletionListenerTest.Event;
+import org.apache.camel.processor.OnCompletionListenerTest.Recorder;
+import org.apache.camel.processor.OnCompletionListenerTest.Type;
+import org.apache.camel.spi.UnitOfWork;
+import org.apache.camel.spi.UnitOfWorkFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * With a custom {@link UnitOfWork} that does not implement
+ * {@link UnitOfWork#handoverSynchronizationWithResult(Exchange, Predicate)}, 
the on completions are handed over as
+ * without a listener, and the listener is told that it is not known what 
moved.
+ */
+class OnCompletionListenerCustomUnitOfWorkTest extends ContextTestSupport {
+
+    private final Recorder recorder = new Recorder();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        
context.getCamelContextExtension().addContextPlugin(UnitOfWorkFactory.class, 
CustomUnitOfWork::create);
+        return context;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start").to("seda:queue");
+                from("seda:queue").to("mock:queue");
+            }
+        };
+    }
+
+    @Test
+    void testHandoverThatDoesNotTellWhatMoved() throws Exception {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit commit = new Commit("commit");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:start", exchange);
+        commit.awaitRun();
+
+        Event handover = recorder.events.stream()
+                .filter(e -> e.type() == Type.HANDOVER && e.exchange() == 
exchange)
+                .findFirst().orElseThrow();
+        assertNull(handover.list());
+        assertEquals(1, commit.runs.get());
+        // it ran with the copy, as it was handed over
+        Event run = recorder.events.stream()
+                .filter(e -> e.type() == Type.RUN && e.onCompletion() == 
commit)
+                .findFirst().orElseThrow();
+        assertEquals(List.of(run.exchange()), List.of(handover.to()));
+    }
+
+    /**
+     * A unit of work that delegates to the default one, but uses the default 
method of the interface to hand over.
+     */
+    private static final class CustomUnitOfWork {
+
+        static UnitOfWork create(Exchange exchange) {
+            DefaultUnitOfWork delegate = new DefaultUnitOfWork(exchange);
+            InvocationHandler handler = (proxy, method, args) -> {
+                if (method.isDefault() && 
"handoverSynchronizationWithResult".equals(method.getName())) {
+                    return InvocationHandler.invokeDefault(proxy, method, 
args);
+                }
+                try {
+                    return method.invoke(delegate, args);
+                } catch (InvocationTargetException e) {
+                    throw e.getCause();
+                }
+            };
+            return (UnitOfWork) 
Proxy.newProxyInstance(CustomUnitOfWork.class.getClassLoader(),
+                    new Class<?>[] { UnitOfWork.class }, handler);
+        }
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/OnCompletionListenerTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/OnCompletionListenerTest.java
new file mode 100644
index 000000000000..633dd403d568
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/OnCompletionListenerTest.java
@@ -0,0 +1,492 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.processor;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.OnCompletionListener;
+import org.apache.camel.spi.Synchronization;
+import org.apache.camel.spi.SynchronizationVetoable;
+import org.apache.camel.spi.UnitOfWork;
+import org.apache.camel.support.UnitOfWorkHelper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The {@link OnCompletionListener} sees each move, add, handover, take and 
run of an on completion, on the paths that
+ * Camel moves them today: a commit handed over to a copy sent to seda, the 
copy that seda does not queue giving it back
+ * (CAMEL-25123), and the parts of a split with shareUnitOfWork, which hand 
over only their own on completions
+ * (CAMEL-26576). With no listener, nothing changes.
+ */
+class OnCompletionListenerTest extends ContextTestSupport {
+
+    private static final long WAIT_SECONDS = 10;
+
+    private final Recorder recorder = new Recorder();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start").to("seda:queue");
+                from("seda:queue").to("mock:queue");
+
+                // no consumer, so the first message fills the queue and the 
next one is discarded
+                
from("direct:full").to("seda:full?size=1&discardWhenFull=true");
+
+                from("direct:share")
+                        .split(body().tokenize(",")).shareUnitOfWork()
+                            .process(e -> 
e.getExchangeExtension().addOnCompletion((Commit) e.getProperty("own")))
+                            .to("seda:parts")
+                        .end();
+                from("seda:parts").to("mock:parts");
+
+                from("direct:remove")
+                        .process(e -> 
e.getUnitOfWork().addSynchronization((Commit) e.getProperty("removed")))
+                        .process(e -> 
e.getUnitOfWork().removeSynchronization((Commit) e.getProperty("removed")))
+                        // it is no longer there, so this one is not reported
+                        .process(e -> 
e.getUnitOfWork().removeSynchronization((Commit) e.getProperty("removed")));
+            }
+        };
+    }
+
+    @Test
+    void testHandoverToACopySentToSeda() throws Exception {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit commit = new Commit("commit");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:start", exchange);
+        commit.awaitRun();
+
+        assertEquals(List.of(
+                "unitOfWork e1 u1 [commit]",
+                "added u1 commit",
+                "handover e1 u1 -> e2 [commit]",
+                "unitOfWork e2 u2 [commit]",
+                "added u2 commit",
+                "run e2 commit complete"),
+                recorder.lines(commit));
+        assertSame(exchange, recorder.events.peek().exchange);
+        assertEquals(1, commit.runs.get());
+    }
+
+    @Test
+    void testCopyNotQueuedGivesItBack() throws Exception {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit queued = new Commit("queued");
+        Commit discarded = new Commit("discarded");
+
+        Exchange first = createExchangeWithBody("A");
+        first.getExchangeExtension().addOnCompletion(queued);
+        template.send("direct:full", first);
+        Exchange second = createExchangeWithBody("B");
+        second.getExchangeExtension().addOnCompletion(discarded);
+        template.send("direct:full", second);
+        discarded.awaitRun();
+
+        // the copy has no unit of work, so it gives the commit back from its 
own list, and the exchange runs it
+        assertEquals(List.of(
+                "unitOfWork e1 u1 [discarded]",
+                "added u1 discarded",
+                "handover e1 u1 -> e2 [discarded]",
+                "added u1 discarded",
+                "handover e2 - -> e1 [discarded]",
+                "run e1 discarded complete"),
+                recorder.lines(discarded));
+        assertEquals(1, discarded.runs.get());
+        // the queued copy holds the other commit, which has not run
+        assertEquals(List.of(
+                "unitOfWork e1 u1 [queued]",
+                "added u1 queued",
+                "handover e1 u1 -> e2 [queued]"),
+                recorder.lines(queued));
+        assertEquals(0, queued.runs.get());
+    }
+
+    @Test
+    void testSharedUnitOfWorkHandsOverOnlyThePartsOwn() throws Exception {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit commit = new Commit("commit");
+        Commit own = new Commit("own");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.setProperty("own", own);
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:share", exchange);
+        commit.awaitRun();
+        own.awaitRun();
+
+        // the commit of the parent stays in the shared unit of work, and runs 
when the parent is done
+        assertEquals(List.of(
+                "unitOfWork e1 u1 [commit]",
+                "added u1 commit",
+                "run e1 commit complete"),
+                recorder.lines(commit));
+        // the part hands over the on completion it added, which runs with the 
copy
+        assertEquals(List.of(
+                "added u1 own",
+                "handover e1 u1 -> e2 [own]",
+                "unitOfWork e2 u2 [own]",
+                "added u2 own",
+                "run e2 own complete"),
+                recorder.lines(own));
+        // the part was given the unit of work of the parent
+        Event part = recorder.events.stream()
+                .filter(e -> e.type == Type.UNIT_OF_WORK && e.exchange != 
exchange
+                        && e.unitOfWork == recorder.events.peek().unitOfWork)
+                .findFirst().orElseThrow();
+        assertTrue(part.list.isEmpty());
+        assertEquals(1, commit.runs.get());
+        assertEquals(1, own.runs.get());
+    }
+
+    @Test
+    void testVetoedHandoverIsNotReported() throws Exception {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit commit = new VetoedCommit("vetoed");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:start", exchange);
+        commit.awaitRun();
+
+        // it refuses the handover to the copy sent to seda, so it stays and 
runs with the exchange
+        assertEquals(List.of(
+                "unitOfWork e1 u1 [vetoed]",
+                "added u1 vetoed",
+                "run e1 vetoed complete"),
+                recorder.lines(commit));
+        assertEquals(1, commit.runs.get());
+        // nothing moved, so there is no handover event (and never one with an 
empty list)
+        assertTrue(recorder.events.stream().noneMatch(e -> e.type == 
Type.HANDOVER && e.exchange == exchange));
+        assertTrue(recorder.events.stream().noneMatch(e -> e.list != null && 
e.list.isEmpty() && e.type != Type.UNIT_OF_WORK));
+    }
+
+    @Test
+    void testRemoved() {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit removed = new Commit("removed");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.setProperty("removed", removed);
+        template.send("direct:remove", exchange);
+
+        assertEquals(List.of(
+                "added u1 removed",
+                "removed u1 removed"),
+                recorder.lines(removed));
+        assertEquals(0, removed.runs.get());
+    }
+
+    @Test
+    void testTakenAndRunBySomeoneElse() {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit commit = new Commit("commit");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        // as the aggregator, the enrichers and onCompletion do with an 
exchange that has no unit of work
+        List<Synchronization> taken = 
exchange.getExchangeExtension().handoverCompletions();
+        exchange.setException(new IllegalStateException("Forced"));
+        UnitOfWorkHelper.doneSynchronizations(exchange, taken);
+
+        assertEquals(List.of(
+                "taken e1 [commit]",
+                "run e1 commit failure"),
+                recorder.lines(commit));
+        assertEquals(1, commit.runs.get());
+        // the listener gets a view of the list, which it cannot change
+        Event event = recorder.events.peek();
+        assertThrows(UnsupportedOperationException.class, () -> 
event.list.clear());
+        assertEquals(1, taken.size());
+    }
+
+    @Test
+    void testNoListener() throws Exception {
+        
assertTrue(context.getCamelContextExtension().getOnCompletionListeners().isEmpty());
+        Commit commit = new Commit("commit");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:start", exchange);
+        commit.awaitRun();
+
+        assertEquals(1, commit.runs.get());
+    }
+
+    @Test
+    void testRemovedListenerIsNotCalled() throws Exception {
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        // an equal listener is added once
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        assertEquals(List.of(recorder), 
context.getCamelContextExtension().getOnCompletionListeners());
+        
context.getCamelContextExtension().removeOnCompletionListener(recorder);
+        
assertTrue(context.getCamelContextExtension().getOnCompletionListeners().isEmpty());
+
+        Commit commit = new Commit("commit");
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:start", exchange);
+        commit.awaitRun();
+
+        assertTrue(recorder.events.isEmpty());
+        assertEquals(1, commit.runs.get());
+    }
+
+    @Test
+    void testFailingListenerDoesNotChangeTheRouting() throws Exception {
+        OnCompletionListener failing = new OnCompletionListener() {
+            @Override
+            public void onUnitOfWork(Exchange exchange, UnitOfWork unitOfWork, 
List<Synchronization> moved) {
+                throw new IllegalStateException("Forced");
+            }
+
+            @Override
+            public void onCompletionAdded(UnitOfWork unitOfWork, 
Synchronization onCompletion) {
+                throw new IllegalStateException("Forced");
+            }
+
+            @Override
+            public void onCompletionHandover(
+                    Exchange from, UnitOfWork fromUnitOfWork, Exchange to, 
List<Synchronization> moved) {
+                throw new IllegalStateException("Forced");
+            }
+
+            @Override
+            public void onCompletionRemoved(UnitOfWork unitOfWork, 
Synchronization onCompletion) {
+                throw new IllegalStateException("Forced");
+            }
+
+            @Override
+            public void onCompletionTaken(Exchange from, List<Synchronization> 
taken) {
+                throw new IllegalStateException("Forced");
+            }
+
+            @Override
+            public void onCompletionRun(Exchange exchange, Synchronization 
onCompletion, boolean failure) {
+                throw new IllegalStateException("Forced");
+            }
+        };
+        context.getCamelContextExtension().addOnCompletionListener(failing);
+        context.getCamelContextExtension().addOnCompletionListener(recorder);
+        Commit commit = new Commit("commit");
+
+        Exchange exchange = createExchangeWithBody("A");
+        exchange.getExchangeExtension().addOnCompletion(commit);
+        template.send("direct:start", exchange);
+        commit.awaitRun();
+
+        assertFalse(exchange.isFailed());
+        assertEquals(1, commit.runs.get());
+        assertEquals(6, recorder.lines(commit).size());
+
+        // also when it fails on a removal and on a take
+        Commit removed = new Commit("removed");
+        Exchange other = createExchangeWithBody("B");
+        other.setProperty("removed", removed);
+        template.send("direct:remove", other);
+        assertFalse(other.isFailed());
+        assertEquals(List.of("added u1 removed", "removed u1 removed"), 
recorder.lines(removed));
+
+        Commit taken = new Commit("taken");
+        Exchange third = createExchangeWithBody("C");
+        third.getExchangeExtension().addOnCompletion(taken);
+        assertEquals(List.of(taken), 
third.getExchangeExtension().handoverCompletions());
+        assertEquals(List.of("taken e1 [taken]"), recorder.lines(taken));
+    }
+
+    @Test
+    void testUnitOfWorkThatDoesNotTellWhatMoved() {
+        List<String> calls = new ArrayList<>();
+        Exchange target = createExchangeWithBody("B");
+        InvocationHandler handler = (proxy, method, args) -> {
+            if (method.isDefault()) {
+                return InvocationHandler.invokeDefault(proxy, method, args);
+            }
+            calls.add(method.getName() + " " + args.length);
+            return null;
+        };
+        UnitOfWork custom = (UnitOfWork) 
Proxy.newProxyInstance(getClass().getClassLoader(),
+                new Class<?>[] { UnitOfWork.class }, handler);
+
+        // a custom unit of work that does not implement the new method hands 
over as before, and tells nothing
+        assertNull(custom.handoverSynchronizationWithResult(target, null));
+        assertNull(custom.handoverSynchronizationWithResult(target, s -> 
true));
+        assertEquals(List.of("handoverSynchronization 1", 
"handoverSynchronization 2"), calls);
+    }
+
+    enum Type {
+        UNIT_OF_WORK,
+        ADDED,
+        REMOVED,
+        HANDOVER,
+        TAKEN,
+        RUN
+    }
+
+    record Event(Type type, Exchange exchange, UnitOfWork unitOfWork, Exchange 
to, List<Synchronization> list,
+            Synchronization onCompletion, boolean failure) {
+    }
+
+    /**
+     * An on completion that counts its runs.
+     */
+    static class Commit implements Synchronization {
+        private final String name;
+        final AtomicInteger runs = new AtomicInteger();
+        private final CountDownLatch run = new CountDownLatch(1);
+
+        Commit(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public void onComplete(Exchange exchange) {
+            runs.incrementAndGet();
+            run.countDown();
+        }
+
+        @Override
+        public void onFailure(Exchange exchange) {
+            runs.incrementAndGet();
+            run.countDown();
+        }
+
+        void awaitRun() throws InterruptedException {
+            assertTrue(run.await(WAIT_SECONDS, TimeUnit.SECONDS), name + " did 
not run");
+        }
+
+        @Override
+        public String toString() {
+            return name;
+        }
+    }
+
+    /**
+     * An on completion that refuses to be handed over.
+     */
+    static final class VetoedCommit extends Commit implements 
SynchronizationVetoable {
+        VetoedCommit(String name) {
+            super(name);
+        }
+
+        @Override
+        public boolean allowHandover() {
+            return false;
+        }
+
+        @Override
+        public void beforeHandover(Exchange target) {
+            // not handed over
+        }
+    }
+
+    /**
+     * Records the events, and renders those of one on completion as lines, 
with the exchanges and the units of work
+     * named in the order they first appear in those lines.
+     */
+    static final class Recorder implements OnCompletionListener {
+        final Queue<Event> events = new ConcurrentLinkedQueue<>();
+
+        @Override
+        public void onUnitOfWork(Exchange exchange, UnitOfWork unitOfWork, 
List<Synchronization> moved) {
+            events.add(new Event(Type.UNIT_OF_WORK, exchange, unitOfWork, 
null, moved, null, false));
+        }
+
+        @Override
+        public void onCompletionAdded(UnitOfWork unitOfWork, Synchronization 
onCompletion) {
+            events.add(new Event(Type.ADDED, null, unitOfWork, null, null, 
onCompletion, false));
+        }
+
+        @Override
+        public void onCompletionRemoved(UnitOfWork unitOfWork, Synchronization 
onCompletion) {
+            events.add(new Event(Type.REMOVED, null, unitOfWork, null, null, 
onCompletion, false));
+        }
+
+        @Override
+        public void onCompletionHandover(
+                Exchange from, UnitOfWork fromUnitOfWork, Exchange to, 
List<Synchronization> moved) {
+            events.add(new Event(Type.HANDOVER, from, fromUnitOfWork, to, 
moved, null, false));
+        }
+
+        @Override
+        public void onCompletionTaken(Exchange from, List<Synchronization> 
taken) {
+            events.add(new Event(Type.TAKEN, from, null, null, taken, null, 
false));
+        }
+
+        @Override
+        public void onCompletionRun(Exchange exchange, Synchronization 
onCompletion, boolean failure) {
+            events.add(new Event(Type.RUN, exchange, null, null, null, 
onCompletion, failure));
+        }
+
+        List<String> lines(Synchronization onCompletion) {
+            Predicate<Event> about = e -> e.onCompletion == onCompletion
+                    || e.list != null && e.list.stream().anyMatch(s -> s == 
onCompletion);
+            Map<Object, String> names = new IdentityHashMap<>();
+            List<String> lines = new ArrayList<>();
+            for (Event e : events) {
+                if (!about.test(e)) {
+                    continue;
+                }
+                String line = switch (e.type) {
+                    case UNIT_OF_WORK -> "unitOfWork " + name(names, 
e.exchange, "e") + " " + name(names, e.unitOfWork, "u")
+                                         + " " + e.list;
+                    case ADDED -> "added " + name(names, e.unitOfWork, "u") + 
" " + e.onCompletion;
+                    case REMOVED -> "removed " + name(names, e.unitOfWork, 
"u") + " " + e.onCompletion;
+                    case HANDOVER -> "handover " + name(names, e.exchange, 
"e") + " " + name(names, e.unitOfWork, "u")
+                                     + " -> " + name(names, e.to, "e") + " " + 
e.list;
+                    case TAKEN -> "taken " + name(names, e.exchange, "e") + " 
" + e.list;
+                    case RUN -> "run " + name(names, e.exchange, "e") + " " + 
e.onCompletion + " "
+                                + (e.failure ? "failure" : "complete");
+                };
+                lines.add(line);
+            }
+            return lines;
+        }
+
+        private static String name(Map<Object, String> names, Object o, String 
prefix) {
+            if (o == null) {
+                return "-";
+            }
+            return names.computeIfAbsent(o, k -> prefix + 
(names.keySet().stream()
+                    .filter(n -> names.get(n).startsWith(prefix)).count() + 
1));
+        }
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/AbstractExchange.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/AbstractExchange.java
index d4a06e789946..9b4cd0d75a1f 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/AbstractExchange.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/AbstractExchange.java
@@ -17,6 +17,7 @@
 package org.apache.camel.support;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.EnumMap;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -24,6 +25,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Predicate;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
@@ -38,6 +40,7 @@ import org.apache.camel.Message;
 import org.apache.camel.MessageHistory;
 import org.apache.camel.Route;
 import org.apache.camel.SafeCopyProperty;
+import org.apache.camel.spi.OnCompletionListener;
 import org.apache.camel.spi.Synchronization;
 import org.apache.camel.spi.UnitOfWork;
 import org.apache.camel.spi.VariableRepository;
@@ -867,25 +870,49 @@ abstract class AbstractExchange implements Exchange, 
ExchangeExtension {
 
     @Override
     public void handoverCompletions(Exchange target) {
+        final List<OnCompletionListener> listeners = 
OnCompletionListenerHelper.getListeners(context);
+        final boolean listen = !listeners.isEmpty();
         if (onCompletions != null) {
+            final List<Synchronization> moved = listen ? new 
ArrayList<>(onCompletions) : null;
             for (Synchronization onCompletion : onCompletions) {
                 target.getExchangeExtension().addOnCompletion(onCompletion);
             }
             onCompletions.clear();
             onCompletions = null;
+            if (listen && !moved.isEmpty()) {
+                OnCompletionListenerHelper.onCompletionHandover(listeners, 
this, null, target, moved);
+            }
         } else if (unitOfWork != null) {
-            if (sharedUnitOfWork == null || unitOfWork != 
sharedUnitOfWork.unitOfWork) {
-                unitOfWork.handoverSynchronization(target);
+            final UnitOfWork from = unitOfWork;
+            if (sharedUnitOfWork == null || from != 
sharedUnitOfWork.unitOfWork) {
+                if (listen) {
+                    handedOver(listeners, from, target, 
from.handoverSynchronizationWithResult(target, null));
+                } else {
+                    from.handoverSynchronization(target);
+                }
             } else if (sharedUnitOfWork.completions != null) {
                 // the unit of work is shared with the parent exchange 
(shareUnitOfWork), so hand over only the on
                 // completions this exchange added, and not those of the 
parent exchange (such as its consumer's commit)
                 final List<Synchronization> own = sharedUnitOfWork.completions;
                 sharedUnitOfWork.completions = null;
-                unitOfWork.handoverSynchronization(target, s -> 
containsInstance(own, s));
+                final Predicate<Synchronization> filter = s -> 
containsInstance(own, s);
+                if (listen) {
+                    handedOver(listeners, from, target, 
from.handoverSynchronizationWithResult(target, filter));
+                } else {
+                    from.handoverSynchronization(target, filter);
+                }
             }
         }
     }
 
+    private void handedOver(
+            List<OnCompletionListener> listeners, UnitOfWork from, Exchange 
target, List<Synchronization> moved) {
+        // moved is null when the unit of work does not tell what moved
+        if (moved == null || !moved.isEmpty()) {
+            OnCompletionListenerHelper.onCompletionHandover(listeners, this, 
from, target, moved);
+        }
+    }
+
     private static boolean containsInstance(List<Synchronization> list, 
Synchronization synchronization) {
         for (Synchronization s : list) {
             if (s == synchronization) {
@@ -902,6 +929,10 @@ abstract class AbstractExchange implements Exchange, 
ExchangeExtension {
             answer = new ArrayList<>(onCompletions);
             onCompletions.clear();
             onCompletions = null;
+            final List<OnCompletionListener> listeners = 
OnCompletionListenerHelper.getListeners(context);
+            if (!listeners.isEmpty() && !answer.isEmpty()) {
+                OnCompletionListenerHelper.onCompletionTaken(listeners, this, 
answer);
+            }
         }
         return answer;
     }
@@ -913,6 +944,14 @@ abstract class AbstractExchange implements Exchange, 
ExchangeExtension {
             // no longer uses the shared unit of work (such as a copy routed 
on its own)
             sharedUnitOfWork = null;
         }
+        if (unitOfWork != null) {
+            final List<OnCompletionListener> listeners = 
OnCompletionListenerHelper.getListeners(context);
+            if (!listeners.isEmpty()) {
+                // before the on completions move, so the adds of the move 
come after it
+                OnCompletionListenerHelper.onUnitOfWork(listeners, this, 
unitOfWork,
+                        onCompletions != null ? new ArrayList<>(onCompletions) 
: Collections.emptyList());
+            }
+        }
         if (unitOfWork != null && onCompletions != null) {
             for (Synchronization onCompletion : onCompletions) {
                 unitOfWork.addSynchronization(onCompletion);
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/OnCompletionListenerHelper.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/OnCompletionListenerHelper.java
new file mode 100644
index 000000000000..389bb9741a1e
--- /dev/null
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/OnCompletionListenerHelper.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.support;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.spi.OnCompletionListener;
+import org.apache.camel.spi.Synchronization;
+import org.apache.camel.spi.UnitOfWork;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Calls the {@link OnCompletionListener}s, for the places in Camel that 
change or run the on completions of an
+ * exchange. A place gets the listeners with {@link 
#getListeners(CamelContext)} and calls them only when the list is
+ * not empty, so with no listener installed it only checks that the list is 
empty. What a listener throws is caught and
+ * logged at WARN once per listener (then at DEBUG), so it cannot change the 
routing.
+ * <p/>
+ * <b>Important:</b> This API is NOT intended for Camel end users, but used 
internally by Camel itself.
+ *
+ * @since 4.24
+ */
+public final class OnCompletionListenerHelper {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(OnCompletionListenerHelper.class);
+
+    // the listeners that already failed once, so they are logged at WARN once 
(weak keys: a listener is not kept)
+    private static final Map<OnCompletionListener, Boolean> FAILED_LISTENERS
+            = Collections.synchronizedMap(new WeakHashMap<>());
+
+    private OnCompletionListenerHelper() {
+    }
+
+    /**
+     * The listeners installed on the context.
+     *
+     * @return the listeners (a view, not a copy), or an empty list if none is 
installed or there is no context
+     */
+    public static List<OnCompletionListener> getListeners(CamelContext 
context) {
+        final ExtendedCamelContext ecc = context != null ? 
context.getCamelContextExtension() : null;
+        return ecc != null ? ecc.getOnCompletionListeners() : 
Collections.emptyList();
+    }
+
+    public static void onUnitOfWork(
+            List<OnCompletionListener> listeners, Exchange exchange, 
UnitOfWork unitOfWork, List<Synchronization> moved) {
+        final List<Synchronization> view = Collections.unmodifiableList(moved);
+        for (OnCompletionListener listener : listeners) {
+            try {
+                listener.onUnitOfWork(exchange, unitOfWork, view);
+            } catch (Throwable e) {
+                failed(listener, "onUnitOfWork", e);
+            }
+        }
+    }
+
+    public static void onCompletionAdded(
+            List<OnCompletionListener> listeners, UnitOfWork unitOfWork, 
Synchronization onCompletion) {
+        for (OnCompletionListener listener : listeners) {
+            try {
+                listener.onCompletionAdded(unitOfWork, onCompletion);
+            } catch (Throwable e) {
+                failed(listener, "onCompletionAdded", e);
+            }
+        }
+    }
+
+    public static void onCompletionRemoved(
+            List<OnCompletionListener> listeners, UnitOfWork unitOfWork, 
Synchronization onCompletion) {
+        for (OnCompletionListener listener : listeners) {
+            try {
+                listener.onCompletionRemoved(unitOfWork, onCompletion);
+            } catch (Throwable e) {
+                failed(listener, "onCompletionRemoved", e);
+            }
+        }
+    }
+
+    public static void onCompletionHandover(
+            List<OnCompletionListener> listeners, Exchange from, UnitOfWork 
fromUnitOfWork, Exchange to,
+            List<Synchronization> moved) {
+        final List<Synchronization> view = moved != null ? 
Collections.unmodifiableList(moved) : null;
+        for (OnCompletionListener listener : listeners) {
+            try {
+                listener.onCompletionHandover(from, fromUnitOfWork, to, view);
+            } catch (Throwable e) {
+                failed(listener, "onCompletionHandover", e);
+            }
+        }
+    }
+
+    public static void onCompletionTaken(List<OnCompletionListener> listeners, 
Exchange from, List<Synchronization> taken) {
+        final List<Synchronization> view = Collections.unmodifiableList(taken);
+        for (OnCompletionListener listener : listeners) {
+            try {
+                listener.onCompletionTaken(from, view);
+            } catch (Throwable e) {
+                failed(listener, "onCompletionTaken", e);
+            }
+        }
+    }
+
+    public static void onCompletionRun(
+            List<OnCompletionListener> listeners, Exchange exchange, 
Synchronization onCompletion, boolean failure) {
+        for (OnCompletionListener listener : listeners) {
+            try {
+                listener.onCompletionRun(exchange, onCompletion, failure);
+            } catch (Throwable e) {
+                failed(listener, "onCompletionRun", e);
+            }
+        }
+    }
+
+    private static void failed(OnCompletionListener listener, String method, 
Throwable cause) {
+        try {
+            if (FAILED_LISTENERS.put(listener, Boolean.TRUE) == null) {
+                LOG.warn("OnCompletionListener {} failed in {} (logged once 
for this listener, then at DEBUG)", listener,
+                        method, cause);
+            } else {
+                LOG.debug("OnCompletionListener {} failed in {}", listener, 
method, cause);
+            }
+        } catch (Throwable t) {
+            // a listener cannot change the routing, also when logging it 
fails (such as its toString)
+        }
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/UnitOfWorkHelper.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/UnitOfWorkHelper.java
index 5ef62a1e143a..8fb1f769ffbc 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/UnitOfWorkHelper.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/UnitOfWorkHelper.java
@@ -22,6 +22,7 @@ import java.util.List;
 
 import org.apache.camel.Exchange;
 import org.apache.camel.Route;
+import org.apache.camel.spi.OnCompletionListener;
 import org.apache.camel.spi.Synchronization;
 import org.apache.camel.spi.SynchronizationRouteAware;
 import org.apache.camel.spi.UnitOfWork;
@@ -76,6 +77,7 @@ public final class UnitOfWorkHelper {
             return;
         }
 
+        final List<OnCompletionListener> listeners = 
OnCompletionListenerHelper.getListeners(exchange.getContext());
         if (synchronizations.size() > 1) {
             // work on a copy of the list (unless it is private) to avoid any 
modification which may cause
             // ConcurrentModificationException
@@ -85,15 +87,19 @@ public final class UnitOfWorkHelper {
 
             // invoke synchronization callbacks
             for (Synchronization synchronization : ordered) {
-                doneSynchronization(synchronization, exchange, failed);
+                doneSynchronization(synchronization, exchange, failed, 
listeners);
             }
         } else {
             // there are only 1 synchronization to done
-            doneSynchronization(synchronizations.get(0), exchange, 
exchange.isFailed());
+            doneSynchronization(synchronizations.get(0), exchange, 
exchange.isFailed(), listeners);
         }
     }
 
-    private static void doneSynchronization(Synchronization synchronization, 
Exchange exchange, boolean failed) {
+    private static void doneSynchronization(
+            Synchronization synchronization, Exchange exchange, boolean 
failed, List<OnCompletionListener> listeners) {
+        if (!listeners.isEmpty()) {
+            OnCompletionListenerHelper.onCompletionRun(listeners, exchange, 
synchronization, failed);
+        }
         try {
             if (failed) {
                 LOG.trace("Invoking synchronization.onFailure: {} with {}", 
synchronization, exchange);
diff --git a/design/exchange-sanitizer.adoc b/design/exchange-sanitizer.adoc
index edb23d273383..6587422108dc 100644
--- a/design/exchange-sanitizer.adoc
+++ b/design/exchange-sanitizer.adoc
@@ -387,9 +387,13 @@ public interface OnCompletionListener {
     // an on completion is added to a unit of work
     default void onCompletionAdded(UnitOfWork unitOfWork, Synchronization 
onCompletion) { }
 
-    // on completions are handed over from one exchange (and its unit of work, 
if any) to another exchange
+    // an on completion is removed from a unit of work, so it does not run
+    default void onCompletionRemoved(UnitOfWork unitOfWork, Synchronization 
onCompletion) { }
+
+    // on completions are handed over from one exchange (and its unit of work, 
if any) to another exchange; moved is
+    // null when a custom unit of work does not tell what moved
     default void onCompletionHandover(
-            Exchange from, @Nullable UnitOfWork fromUnitOfWork, Exchange to, 
List<Synchronization> moved) { }
+            Exchange from, @Nullable UnitOfWork fromUnitOfWork, Exchange to, 
@Nullable List<Synchronization> moved) { }
 
     // on completions are taken out of an exchange as a list, to be run by 
someone else
     default void onCompletionTaken(Exchange from, List<Synchronization> taken) 
{ }
@@ -418,9 +422,9 @@ They are installed through `ExtendedCamelContext`, as the 
`StepHook` is, with de
 of the order of work); the on completion listener comes with R2 (step 3).
 
 The API changes in short: the two interfaces; the six methods on 
`ExtendedCamelContext`; and, for R2, a new default
-method on `UnitOfWork` that hands over and returns what moved 
(`handoverSynchronization(Exchange from, Exchange
-target, Predicate<Synchronization> filter)`), as today only 
`DefaultUnitOfWork` knows which on completions a
-`SynchronizationVetoable` kept, and only the exchange knows that it is the one 
handing over.
+method on `UnitOfWork` that hands over and returns what moved 
(`handoverSynchronizationWithResult(Exchange target,
+Predicate<Synchronization> filter)`), as today only `DefaultUnitOfWork` knows 
which on completions a
+`SynchronizationVetoable` kept.
 
 Rules for listeners, in the Javadoc:
 
@@ -488,12 +492,14 @@ wrapped callback; that is a development cost, measured 
before the first pull req
 
 *On completions.* The on completion listener is called where Camel changes or 
runs on completions today:
 
-* `onUnitOfWork` in `setUnitOfWork` of the exchange (in `AbstractExchange` and 
`ExtendedExchangeExtension`), with the
-  on completions it moves into the unit of work;
+* `onUnitOfWork` in `setUnitOfWork` of the exchange (in `AbstractExchange`; 
`ExtendedExchangeExtension` is no longer
+  used since CAMEL-23686), with the on completions it moves into the unit of 
work;
 * `onCompletionAdded` in `DefaultUnitOfWork.addSynchronization`. An add that 
is part of a move or of a handover is
   reported there too. The listener tells them apart: `onUnitOfWork` is fired 
before the move, and `onCompletionHandover`
   right after the handover, on the same thread, so the adds in between belong 
to them. Adds to an exchange's own list
   (before it has a unit of work) are not reported; they show up in the move;
+* `onCompletionRemoved` in `DefaultUnitOfWork.removeSynchronization`, when it 
removed one, as a removed commit does not
+  run (see `Synchronization`);
 * `onCompletionHandover` in `handoverCompletions(target)` of the exchange, 
which knows the exchange that hands over and,
   through the new `UnitOfWork` method, what moved;
 * `onCompletionTaken` in `handoverCompletions()` without a target;

Reply via email to