[ 
https://issues.apache.org/jira/browse/CAMEL-24583?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Henrik updated CAMEL-24583:
---------------------------
    Description: 
{{MasterConsumer}} starts the delegated consumer asynchronously, from a 
{{BackgroundTask}} scheduled one second after the leadership-taken event. A 
leadership-lost event that arrives inside that window is discarded, and the 
task then starts the consumer on a node that is no longer the leader. Nothing 
stops it afterwards, because no further leadership event is coming: the node is 
already a follower.

The result is two active consumers on an endpoint that {{master:}} exists to 
keep single-consumer.

Line numbers below are 4.22.0.
 # {{onLeadershipTaken()}} (line 154) holds {{lock}} only long enough to 
schedule the task (line 166). {{delegatedConsumer}} is still null when it 
returns.
 # The scheduled body's only guard is {{if (!isRunAllowed()) return false;}} 
(line 167). {{isRunAllowed()}} is the consumer's own {{BaseService}} status, 
that is, whether the route is running. It says nothing about leadership.
 # {{LeadershipListener.leadershipChanged()}} (line 235) dispatches to 
{{onLeadershipLost()}} only {{else if (delegatedConsumer != null)}} (line 245). 
While the start is pending that field is null, so the lost event is dropped and 
nothing cancels the pending task.
 # One second later (line 147, {{{}withInitialDelay(Duration.ofSeconds(1)){}}}, 
not configurable) the task creates and starts the delegated consumer. The node 
is a follower.

h2. Impact

Observed in production on Camel 4.21.0 with {{KubernetesClusterService}} and a 
google-pubsub delegate. A three second leadership flap produced repeated  
taken/lost cycles on the same endpoint, at sub-second intervals well inside  
the one second start delay:                                                     
                                                                                
 
{code}
    16:01:27.567  Leadership lost. Consumer stopped: 
google-pubsub://<project>:<Queue>                                               
                              
    16:01:28.671  Leadership taken. Attempt #1 to start consumer: 
google-pubsub://<project>:<Queue>                                               
                 
    16:01:29.932  Stopping subscribers for <project>/<Queue>                    
                                                                                
   
{code}                                                                          
                                                                                
     
That is the precondition for the race: a lost event arriving while a start is 
pending is dropped, because the listener guards it on delegatedConsumer != null.

h2. Reproducer

Fake the cluster view so leadership can be flapped on demand, then assert that 
the delegated consumer is never started:
{code:java}
// view.getLocalMember().isLeader() returns leader.get()
// masterEndpoint.getEndpoint() returns a mock Endpoint whose createConsumer() 
is observable

consumer = new MasterConsumer(masterEndpoint, processor, clusterService);
consumer.start();

leader.set(true);
leadershipListener.leadershipChanged(view, localMember);   // taken
leader.set(false);
leadershipListener.leadershipChanged(view, localMember);   // lost, start still 
pending

Thread.sleep(3000);                                        // outlast the 1s 
initial delay

verify(delegatedEndpoint, never()).createConsumer(any());  // fails: the 
consumer was started
{code}
h2. Two related observations in the same class
 * {{BackgroundTask.schedule()}} returns the {{scheduleWithFixedDelay}} future, 
but {{MasterConsumer }}neither retains nor cancels it. The task only 
short-circuits through its own latch ({{{}BackgroundTask.runTaskWrapper{}}}, 
{{{}if (latch.getCount() == 0) return;{}}}), so every leadership change
leaves a repeating no-op task on the "Leadership" pool for the life of the 
route.
 * If {{ServiceHelper.startService()}} throws, {{delegatedConsumer}} has 
already been assigned and is left non-null but unstarted. Once the retry budget 
is exhausted, {{{}onLeadershipTaken(){}}}'s {{if (delegatedConsumer != null) 
return;}} early-return means a later leadership-taken event schedules
nothing, and the node stays nominally leader while consuming nothing until it 
restarts.

h2. Proposed fix
 * Track the leadership taken/lost state, guarded by the same lock the start 
and the stop take, and re-check it inside the scheduled task before creating 
the delegated consumer.
 * Read the leadership inside that lock in the listener, and dispatch the lost 
event unconditionally, so a lost event during a pending start cancels it 
instead of being dropped.
 * Retain the {{Future}} from {{BackgroundTask.schedule()}} and cancel it on 
leadership lost, on a successful start, and on stop.
 * Publish {{delegatedConsumer}} only after it has started, so a failed attempt 
cannot make every later leadership term a no-op.

A patch with tests is ready; the {{BackgroundTask}} half (a scheduled task 
should unschedule itself once it has completed or run out of budget) is 
proposed as a separate camel-support issue.

  was:
{{MasterConsumer}} starts the delegated consumer asynchronously, from a 
{{BackgroundTask}} scheduled one second after the leadership-taken event. A 
leadership-lost event that arrives inside that window is discarded, and the 
task then starts the consumer on a node that is no longer the leader. Nothing 
stops it afterwards, because no further leadership event is coming: the node is 
already a follower.

The result is two active consumers on an endpoint that {{master:}} exists to 
keep single-consumer.

Line numbers below are 4.22.0.
 # {{onLeadershipTaken()}} (line 154) holds {{lock}} only long enough to 
schedule the task (line 166). {{delegatedConsumer}} is still null when it 
returns.
 # The scheduled body's only guard is {{if (!isRunAllowed()) return false;}} 
(line 167). {{isRunAllowed()}} is the consumer's own {{BaseService}} status, 
that is, whether the route is running. It says nothing about leadership.
 # {{LeadershipListener.leadershipChanged()}} (line 235) dispatches to 
{{onLeadershipLost()}} only {{else if (delegatedConsumer != null)}} (line 245). 
While the start is pending that field is null, so the lost event is dropped and 
nothing cancels the pending task.
 # One second later (line 147, {{{}withInitialDelay(Duration.ofSeconds(1)){}}}, 
not configurable) the task creates and starts the delegated consumer. The node 
is a follower.

h2. Impact

Seen in production with a Google Pub/Sub delegate behind 
{{{}KubernetesClusterService{}}}. A three second membership flap left 
subscribers running on the follower pod. Those subscribers kept pulling 
messages into routes whose error handlers had been stopped, so every exchange 
failed with {{java.util.concurrent.RejectedExecutionException}} from 
{{RedeliveryErrorHandler$RedeliveryTask.run}} (the {{isRunAllowed()}} branch) 
and the message was lost rather than redelivered to the leader. The pod 
consumed and discarded messages for 54 minutes until it was restarted.
h2. Reproducer

Fake the cluster view so leadership can be flapped on demand, then assert that 
the delegated consumer is never started:
{code:java}
// view.getLocalMember().isLeader() returns leader.get()
// masterEndpoint.getEndpoint() returns a mock Endpoint whose createConsumer() 
is observable

consumer = new MasterConsumer(masterEndpoint, processor, clusterService);
consumer.start();

leader.set(true);
leadershipListener.leadershipChanged(view, localMember);   // taken
leader.set(false);
leadershipListener.leadershipChanged(view, localMember);   // lost, start still 
pending

Thread.sleep(3000);                                        // outlast the 1s 
initial delay

verify(delegatedEndpoint, never()).createConsumer(any());  // fails: the 
consumer was started
{code}
h2. Two related observations in the same class
 * {{BackgroundTask.schedule()}} returns the {{scheduleWithFixedDelay}} future, 
but {{MasterConsumer }}neither retains nor cancels it. The task only 
short-circuits through its own latch ({{{}BackgroundTask.runTaskWrapper{}}}, 
{{{}if (latch.getCount() == 0) return;{}}}), so every leadership change
leaves a repeating no-op task on the "Leadership" pool for the life of the 
route.
 * If {{ServiceHelper.startService()}} throws, {{delegatedConsumer}} has 
already been assigned and is left non-null but unstarted. Once the retry budget 
is exhausted, {{{}onLeadershipTaken(){}}}'s {{if (delegatedConsumer != null) 
return;}} early-return means a later leadership-taken event schedules
nothing, and the node stays nominally leader while consuming nothing until it 
restarts.

h2. Proposed fix
 * Track the leadership taken/lost state, guarded by the same lock the start 
and the stop take, and re-check it inside the scheduled task before creating 
the delegated consumer.
 * Read the leadership inside that lock in the listener, and dispatch the lost 
event unconditionally, so a lost event during a pending start cancels it 
instead of being dropped.
 * Retain the {{Future}} from {{BackgroundTask.schedule()}} and cancel it on 
leadership lost, on a successful start, and on stop.
 * Publish {{delegatedConsumer}} only after it has started, so a failed attempt 
cannot make every later leadership term a no-op.

A patch with tests is ready; the {{BackgroundTask}} half (a scheduled task 
should unschedule itself once it has completed or run out of budget) is 
proposed as a separate camel-support issue.


> camel-master: losing leadership while a consumer start is pending leaves the 
> delegated consumer running on a non-leader node
> ----------------------------------------------------------------------------------------------------------------------------
>
>                 Key: CAMEL-24583
>                 URL: https://issues.apache.org/jira/browse/CAMEL-24583
>             Project: Camel
>          Issue Type: Bug
>          Components: camel-master
>    Affects Versions: 4.18.3, 4.20.0, 4.21.0, 4.22.0
>            Reporter: Henrik
>            Priority: Major
>
> {{MasterConsumer}} starts the delegated consumer asynchronously, from a 
> {{BackgroundTask}} scheduled one second after the leadership-taken event. A 
> leadership-lost event that arrives inside that window is discarded, and the 
> task then starts the consumer on a node that is no longer the leader. Nothing 
> stops it afterwards, because no further leadership event is coming: the node 
> is already a follower.
> The result is two active consumers on an endpoint that {{master:}} exists to 
> keep single-consumer.
> Line numbers below are 4.22.0.
>  # {{onLeadershipTaken()}} (line 154) holds {{lock}} only long enough to 
> schedule the task (line 166). {{delegatedConsumer}} is still null when it 
> returns.
>  # The scheduled body's only guard is {{if (!isRunAllowed()) return false;}} 
> (line 167). {{isRunAllowed()}} is the consumer's own {{BaseService}} status, 
> that is, whether the route is running. It says nothing about leadership.
>  # {{LeadershipListener.leadershipChanged()}} (line 235) dispatches to 
> {{onLeadershipLost()}} only {{else if (delegatedConsumer != null)}} (line 
> 245). While the start is pending that field is null, so the lost event is 
> dropped and nothing cancels the pending task.
>  # One second later (line 147, 
> {{{}withInitialDelay(Duration.ofSeconds(1)){}}}, not configurable) the task 
> creates and starts the delegated consumer. The node is a follower.
> h2. Impact
> Observed in production on Camel 4.21.0 with {{KubernetesClusterService}} and 
> a google-pubsub delegate. A three second leadership flap produced repeated  
> taken/lost cycles on the same endpoint, at sub-second intervals well inside  
> the one second start delay:                                                   
>                                                                               
>      
> {code}
>     16:01:27.567  Leadership lost. Consumer stopped: 
> google-pubsub://<project>:<Queue>                                             
>                                 
>     16:01:28.671  Leadership taken. Attempt #1 to start consumer: 
> google-pubsub://<project>:<Queue>                                             
>                    
>     16:01:29.932  Stopping subscribers for <project>/<Queue>                  
>                                                                               
>        
> {code}                                                                        
>                                                                               
>          
> That is the precondition for the race: a lost event arriving while a start is 
> pending is dropped, because the listener guards it on delegatedConsumer != 
> null.
> h2. Reproducer
> Fake the cluster view so leadership can be flapped on demand, then assert 
> that the delegated consumer is never started:
> {code:java}
> // view.getLocalMember().isLeader() returns leader.get()
> // masterEndpoint.getEndpoint() returns a mock Endpoint whose 
> createConsumer() is observable
> consumer = new MasterConsumer(masterEndpoint, processor, clusterService);
> consumer.start();
> leader.set(true);
> leadershipListener.leadershipChanged(view, localMember);   // taken
> leader.set(false);
> leadershipListener.leadershipChanged(view, localMember);   // lost, start 
> still pending
> Thread.sleep(3000);                                        // outlast the 1s 
> initial delay
> verify(delegatedEndpoint, never()).createConsumer(any());  // fails: the 
> consumer was started
> {code}
> h2. Two related observations in the same class
>  * {{BackgroundTask.schedule()}} returns the {{scheduleWithFixedDelay}} 
> future, but {{MasterConsumer }}neither retains nor cancels it. The task only 
> short-circuits through its own latch ({{{}BackgroundTask.runTaskWrapper{}}}, 
> {{{}if (latch.getCount() == 0) return;{}}}), so every leadership change
> leaves a repeating no-op task on the "Leadership" pool for the life of the 
> route.
>  * If {{ServiceHelper.startService()}} throws, {{delegatedConsumer}} has 
> already been assigned and is left non-null but unstarted. Once the retry 
> budget is exhausted, {{{}onLeadershipTaken(){}}}'s {{if (delegatedConsumer != 
> null) return;}} early-return means a later leadership-taken event schedules
> nothing, and the node stays nominally leader while consuming nothing until it 
> restarts.
> h2. Proposed fix
>  * Track the leadership taken/lost state, guarded by the same lock the start 
> and the stop take, and re-check it inside the scheduled task before creating 
> the delegated consumer.
>  * Read the leadership inside that lock in the listener, and dispatch the 
> lost event unconditionally, so a lost event during a pending start cancels it 
> instead of being dropped.
>  * Retain the {{Future}} from {{BackgroundTask.schedule()}} and cancel it on 
> leadership lost, on a successful start, and on stop.
>  * Publish {{delegatedConsumer}} only after it has started, so a failed 
> attempt cannot make every later leadership term a no-op.
> A patch with tests is ready; the {{BackgroundTask}} half (a scheduled task 
> should unschedule itself once it has completed or run out of budget) is 
> proposed as a separate camel-support issue.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to