shashank created CAMEL-26458:
--------------------------------

             Summary: camel-disruptor - multipleConsumers: when a consumer 
fails, the on completions of the exchange run twice, or as a success and as a 
failure
                 Key: CAMEL-26458
                 URL: https://issues.apache.org/jira/browse/CAMEL-26458
             Project: Camel
          Issue Type: Bug
          Components: camel-disruptor
            Reporter: shashank
            Assignee: shashank


Found with a SPIN (Promela) model of the completion of a {{disruptor}} exchange 
by several consumers ({{multipleConsumers=true}}), and reproduced on the real 
classes.

*What happens*

With {{multipleConsumers=true}} each consumer routes its own copy of the 
published exchange and then calls 
{{MultipleConsumerSynchronizedExchange.consumed(result)}}. For an InOnly 
exchange the producer hands the on completions of the caller's exchange over to 
the published copy ({{prepareCopy(exchange, true)}}), so these on completions 
(the rollback or commit of the consumer the message came from, such as a file 
or SQS consumer, the {{Synchronization}}s added by the route) run when 
{{consumed}} calls {{performSynchronization()}}:

{code:java}
if (processedConsumers.incrementAndGet() == expectedConsumers || 
result.getException() != null
        && !resultHandled.getAndSet(true)) {
    if (result.getException() != null) {
        ExchangeHelper.copyResults(getExchange(), result);
    }
    performSynchronization();
}
{code}

The {{resultHandled}} guard only applies to the failure branch ({{&&}} binds 
tighter than {{||}}), so the last consumer always synchronizes, also when a 
consumer that failed earlier already did:
# a consumer fails while another one is still routing: the failure synchronizes 
(onFailure), then the last consumer is done and synchronizes again: the on 
completions run twice ({{onFailure}} twice, the copy still has the exception), 
also when both fail;
# a consumer fails and the last consumer is counted before the failing one 
reaches its {{getAndSet}}: the last consumer synchronizes without the failure 
({{onComplete}}, e.g. the file is moved to {{.camel}}), then the failing one 
synchronizes with it ({{onFailure}} as well).

Effects: the rollback of the consumer runs twice, or a commit and a rollback 
both run for one message (a file consumer may release the read lock and 
in-progress mark of a file that is picked up again meanwhile, an 
{{onCompletion().onFailureOnly()}} runs twice, custom on completions see two 
outcomes). With a waiting producer ({{waitForTaskToComplete}}, InOut) the 
producer's own on completion is on the copy: it ignores the second call, but in 
the second order it runs first without the failure, so the caller gets a 
successful reply although a consumer failed.

*SPIN model* (consumers C = 2 and 3, each finishes with success or failure in 
any order; properties: on completions run once, and with the failure if a 
consumer failed). OnceSync is violated (2 consumers, shortest trail at depth 
33; the 3-consumer run finds it too):
{noformat}
c1: routes its copy, fails            c2: routes its copy, succeeds
c1: incrementAndGet() = 1 (not last) -> failure branch
c2: incrementAndGet() = 2 = expected -> synchronize (resultHandled not looked 
at)
c1: resultHandled.getAndSet(true) = false -> copyResults(failure) -> synchronize
c2: performSynchronization  #1      c1: performSynchronization  #2   -> 
violation
{noformat}
The fix below holds both properties for 2 to 5 consumers (5 consumers: 22 
million states, 8 s; also without partial order reduction for 3 and 4 
consumers); a witness shows the failure path and the all-success path are 
reachable.

*Reproducer* (JUnit, latches, no sleeps): {{from("direct:start")}} adds a 
{{Synchronization}} and sends InOnly to 
{{disruptor:fanout?multipleConsumers=true}}; one consumer route throws at once, 
the other waits on a latch: after the latch is released, {{onFailure}} has run 
twice on main (once with the fix); also when both consumers fail. A consumer 
that fails after the other one is done, and all consumers succeeding, 
synchronize once (unchanged). The second order is forced in a unit test of 
{{consumed()}}, where the failing consumer is held at its first look at the 
exception while the other consumer is done: {{onComplete}} then {{onFailure}} 
on main. A stress run (two consumers that finish at the same moment, one fails, 
2000 messages) gave on main 918 double {{onFailure}} and 37 {{onComplete}} + 
{{onFailure}}; with the fix 2000 single {{onFailure}}.

*Proposal*
A consumer that failed sets a flag before it is counted, and synchronizes with 
its own result if it is the first to claim {{resultHandled}}; the last consumer 
synchronizes only when it claims {{resultHandled}} and no consumer failed. The 
early completion on the first failure stays as it is (the waiting producer gets 
the failure without waiting for the other consumers). Setting the flag before 
counting matters: a fix that only adds the once-guard, or sets the flag after 
counting, still lets the last consumer complete the exchange as a success while 
another consumer failed (SPIN finds that order on such a fix).

Related, not part of this: on the first failure the failure is copied into the 
shared published exchange, so a consumer that gets to that message later routes 
an already failed copy and never processes it (a separate follow-up).

_Filed with Claude Code on behalf of allthingssecurity._



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to