shashank created CAMEL-25570:
--------------------------------

             Summary: camel-aws2-ddb - aws2-ddbstream consumer skips the 
records of a poll it did not route when the route is suspended during the poll
                 Key: CAMEL-25570
                 URL: https://issues.apache.org/jira/browse/CAMEL-25570
             Project: Camel
          Issue Type: Bug
          Components: camel-aws
            Reporter: shashank
            Assignee: shashank


{{Ddb2StreamConsumer.poll}} (main 5987a263107b) gets up to 
{{maxResultsPerRequest}} records (default 100) of a shard, creates one exchange 
per record and calls {{processBatch}} (line 91). After that it stores the next 
shard iterator of the response and the sequence number of the last record of 
the response (lines 93-96), whatever {{processBatch}} has done.

{{processBatch}} stops routing when {{isBatchAllowed()}} returns false (line 
106). {{ScheduledBatchPollingConsumer.isBatchAllowed}} (lines 104-118) does 
that when the consumer is no longer allowed to run (stopping or stopped), or 
when {{deferShutdown}} gave it a {{shutdownRunningTask}} other than 
{{CompleteAllTasks}}. Only {{DefaultShutdownStrategy}} calls {{deferShutdown}} 
(lines 614-616), when it suspends or shuts down routes, with the 
{{shutdownRunningTask}} of the route ({{CompleteCurrentTaskOnly}} by default). 
So the batch ends after the exchange that is being routed when the route is 
suspended at route level:
* {{RouteController.suspendRoute}} ({{AbstractCamelContext}} line 1651), also 
when a route policy calls it ({{RoutePolicySupport.suspendRoute}}, line 156);
* {{CamelContext.suspend()}} ({{AbstractCamelContext}} line 2325).

It does not end when {{consumer.suspend()}} is called directly: 
{{isRunAllowed()}} stays true for a suspended service ({{BaseService}} lines 
349-351) and no {{shutdownRunningTask}} is set, so the poll that is running 
routes all its records. That is what 
{{RoutePolicySupport.suspendOrStopConsumer}} does (line 110), which the route 
policies shipped with Camel use ({{ThrottlingInflightRoutePolicy}}, 
{{ThrottlingExceptionRoutePolicy}}, the quartz {{ScheduledRoutePolicy}}): they 
are not affected. A route with {{shutdownRunningTask(CompleteAllTasks)}} is not 
affected either.

When the route is resumed ({{resumeRoute}}, or {{CamelContext.resume()}} after 
{{suspend()}}), the same consumer continues with the stored iterator, which is 
after the whole response: the records that were not routed are skipped. Nothing 
is logged. With one shard and the defaults that is up to 99 records per 
suspend; the following shards of that poll are requested as well and lose their 
whole response. If the stored iterator has expired meanwhile, the consumer 
continues after {{lastSeenSequenceNumbers}}, which is also the last record of 
the response.

When the route or the context is stopped, the batch is cut in the same way (the 
same {{deferShutdown}} call), but that consumer is not used again: a route that 
is started again gets a new consumer ({{DefaultRoute.gatherRootServices}}, line 
711), which starts from the configured {{streamIteratorType}}. Nothing is 
skipped there because of the stored iterator.

h3. Reproduction

New {{Ddb2StreamConsumerSuspendedBatchTest}} (3 tests) with a real route 
({{aws2-ddbstream://table?streamIteratorType=FROM_START}}) and an in-memory 
{{DynamoDbStreamsClient}} (one shard, iterators are positions). The processor 
of one record, or the {{GetRecords}} call, suspends the route from another 
thread ({{RouteController.suspendRoute}}) and waits until {{isBatchAllowed()}} 
is false; then the route is resumed. On main all 3 end with an error, not a 
failure ({{Tests run: 3, Failures: 0, Errors: 3}}): the assertion is inside an 
Awaitility {{untilAsserted}}, so the result is a {{ConditionTimeoutException}} 
that wraps it, for example {{ConditionTimeout Assertion condition defined as a 
Lambda expression in 
org.apache.camel.component.aws2.ddbstream.Ddb2StreamConsumerSuspendedBatchTest 
records routed after the resume ==> expected: <[1, 2, 3, 4]> but was: <[1, 2]> 
within 20 seconds.}} The assertion messages:
* {{recordsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume}} (records 
1-4, suspend while record 2 is routed): {{records routed after the resume ==> 
expected: <[1, 2, 3, 4]> but was: <[1, 2]>}}.
* {{recordsOfAResponseThatArrivesWhileSuspendingAreRoutedAfterTheResume}} 
(suspend while the request is in flight): {{records routed after the resume ==> 
expected: <[1, 2]> but was: <[]>}}.
* {{laterRecordsAreRoutedOnceAfterABatchWasCutShort}} (suspend at record 1 of 
3, record 4 stored while suspended): {{records routed after the resume ==> 
expected: <[1, 2, 3, 4]> but was: <[1, 4]>}}.

h3. Proposed fix

{{poll}} compares the number of exchanges {{processBatch}} routed with the 
number of records of the response. When the batch was cut short:
* it does not take the next shard iterator of the response;
* when some records were routed, it stores the sequence number of the last 
routed record, and the next poll requests an iterator after it 
({{ShardIteratorHandler.requestFreshShardIterator}}, {{AFTER_SEQUENCE_NUMBER}}, 
the call the expired-iterator path already uses). The request is made by the 
next poll and not at once, because a stop interrupts the polling thread;
* when nothing was routed, the iterator of the request stays and is used again;
* the following shards are not requested by that poll.

The exchanges created for the records that are not routed are given back to the 
exchange factory (on main they are dropped); the next poll creates new ones. A 
pooled exchange is given back with {{done()}}, as it was created with 
{{createExchange(true)}}; 
{{Ddb2StreamConsumerSuspendedBatchPooledExchangeTest}} runs the tests with 
{{PooledExchangeFactory}} and checks the pool statistics (every exchange taken 
is given back once).

4.24 upgrade-guide entry (the skipped records are now routed after the resume). 
No new option. camel-aws2-ddb unit tests with the fix: 75, 0 failures 
(integration tests need LocalStack and were not run).

Other consumers with the same shape (a cursor that is moved for the whole poll, 
and a {{processBatch}} loop on {{isBatchAllowed()}}), read on main and not run: 
{{SlackConsumer}} (the timestamp is set in {{createExchanges}}, lines 152 and 
161, before {{processBatch}}, line 175) and {{GoogleCalendarStreamConsumer}} 
({{selectUndeliveredAndMoveCursor}} is called at line 147, before 
{{processBatch}}, line 220). They are in other modules and not part of this 
change. {{Kinesis2Consumer.processBatch}} has no {{isBatchAllowed()}} check and 
routes every record of the response.

Found with a TLA+ model of a poll (GetRecords, route one record at a time while 
the batch is allowed, end of the batch) with a suspend and a resume as 
environment steps: on main TLC violates {{NoSkip}} ("between two polls the 
consumer has not moved past a record it did not route") in 4 states (GetRecords 
returns 3 records, the route is suspended, the batch ends, the iterator is 
after record 3); it holds with the fix (also with an expired iterator after the 
suspend). Then confirmed with the real consumer and a real route as described.

Affected: main; camel-4.22.x, camel-4.18.x and camel-4.14.x have the same 
{{processBatch}} loop and the same update after it (read, not run).

Duplicate check (2026-10-12): JIRA text "ddbstream suspend" (none), "ddbstream 
isBatchAllowed" (none), "Ddb2StreamConsumer" (1: CAMEL-24156, other items), 
"ddbstream" created since 2025-06-01 (2: CAMEL-24156 and CAMEL-24154, 
resharding); GitHub pull requests: #24861 (merged 2026-07-17, CAMEL-24154) is 
the pull request that carried the ddbstream work of that review (it changes 
{{ShardIteratorHandler}} and its tests), and "Ddb2StreamConsumer" in title or 
body finds #6151 (closed, CAMEL-16594); neither is about a suspended route. 
#24863 (merged 2026-07-18, CAMEL-24156) changes no ddbstream file (in 
camel-aws2-ddb only {{Ddb2JsonDataTypeTransformer}}); no open pull request 
touches {{Ddb2StreamConsumer}}.

_Filed with Claude Code on behalf of allthingssecurity._




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

Reply via email to