shashank created CAMEL-25741:
--------------------------------
Summary: camel-aws2-ddb - aws2-ddbstream consumer leaves a closed
shard before reading it to its end, and starts again from streamIteratorType
when every shard it reads has closed (records lost or routed again)
Key: CAMEL-25741
URL: https://issues.apache.org/jira/browse/CAMEL-25741
Project: Camel
Issue Type: Bug
Components: camel-aws
Reporter: shashank
Assignee: shashank
{{ShardIteratorHandler.getShardIterators()}} (camel-aws2-ddb, main
94ca50181230, lines 53-96) decides which shards the {{aws2-ddbstream}} consumer
reads at each poll from two signals, and neither means that a shard was read:
# *A tracked shard that has children in the shard tree is replaced by its
children* (lines 71-80, "Inactive shard, move down to its children"). When
DynamoDB closes a shard it creates its child shards, which {{DescribeStream}}
then lists with the closed shard as {{ParentShardId}}; the closed shard still
holds the records the consumer has not read yet, and the [DynamoDB Streams
guide|https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html]
says "an application must always process a parent shard before it processes a
child shard". {{GetRecords}} returns no {{NextShardIterator}} only when a
closed shard was read to its end
([GetRecords|https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_streams_GetRecords.html]:
"If set to null, the shard has been closed and the requested iterator will not
return any more data"). Consequences:
#* {{streamIteratorType=FROM_START}}: the first poll populates the tree with
every shard of the last 24 hours. The roots are closed and already have
children, so the second poll leaves every root after one response
({{maxResultsPerRequest}} records, default 100, divided by the number of
shards). The rest of the root shards is never routed.
#* any iterator type, table with several partitions: when one shard is read to
its end, the tree is refreshed; every other tracked shard that has closed
meanwhile (a shard has a lifespan of up to four hours, [AWS Database
Blog|https://aws.amazon.com/blogs/database/build-scalable-event-driven-architectures-with-amazon-dynamodb-and-aws-lambda/])
is left with its unread records.
# *An empty map of shard iterators re-initialises the consumer from
{{streamIteratorType}}* (line 58). The map is empty when every shard the
consumer was reading has been read to its end: a table with one partition (one
open shard) at each shard rotation, or several shards read to their end in the
same poll. {{pendingClosedShards}} (CAMEL-24154) is not consulted on that path.
Consequences:
#* {{FROM_START}}: the consumer reads the stream again from its roots and
routes their first records again (then signal 1 moves it down the tree one
response per shard); when the closed roots are read to their end in one
response, the map is empty again at the next poll, so the consumer routes them
again at every poll and never reads the child shards.
#* {{FROM_LATEST}} (the default): the consumer takes {{LATEST}} iterators of
the new shards and skips the records that were written to them between the
rotation and the poll.
Example (one partition, default {{FROM_LATEST}}): the consumer reads
{{shard-0}}; DynamoDB rotates it into {{shard-1}}; the application writes
record 4 (into {{shard-1}}); the consumer reads the last record of {{shard-0}}
(no next iterator), the map is empty, the next poll takes {{LATEST}} of
{{shard-1}}: record 4 is never routed. CAMEL-24154 described this case ("On a
single-partition table the map does empty, but re-initialization uses the
configured iterator type") and its fix covers only the case where another shard
is still tracked.
*Fix* (one class): the configured iterator type is used once, for the start
position (again only while no shard was found at all); a shard is left only
when {{GetRecords}} returned no next shard iterator, and its children are then
read from {{TRIM_HORIZON}} (as before); a shard read to its end whose children
{{DescribeStream}} does not list yet is looked up again at the next poll. The
existing unit test
{{ShardIteratorHandlerTest.shouldProgressThroughTreeWhenShardIteratorsAreRetrievedRepeatedly}}
expected the move to the children at each call without any record read; it now
reads each shard to its end before the next call.
*Tests*: new {{Ddb2StreamConsumerShardRotationTest}} (5 tests, a real route and
an in-memory {{DynamoDbStreamsClient}} with closed and open shards): a closed
root with 5 records and {{maxResultsPerRequest=2}} ({{FROM_START}}; main routes
1, 2 and the child's record only); a closed root and its child ({{FROM_START}};
main routes 1, 2, 1, 2, ... and never 3); the child of the only shard
({{FROM_LATEST}}; main never routes record 4); two shards that rotate together
({{FROM_LATEST}}; main skips five records of the longer shard); a child listed
only after the consumer read the end of its parent ({{FROM_LATEST}}; main skips
its records). All five end with an error on main (an Awaitility
{{ConditionTimeoutException}} around the assertion; the second test routed 2906
records instead of 3 in 20 seconds) and pass with the fix; the camel-aws2-ddb
suite passes (82 tests).
*How it was found*: deterministic simulation testing with a fake DynamoDB
Streams world written from the DynamoDB Streams API reference (shard history,
writes, rotations and splits, faults on every call; the seed also picked
{{streamIteratorType}}, {{maxResultsPerRequest}} 1 to 1000, the poll delay,
{{greedy}} and the {{DescribeStream}} page size). The invariants "every record
the consumer is expected to read is routed" and "no record is routed twice"
failed in 1253 of 3000 seeds on main (first failing seed 3, invariant "every
expected record is routed", {{FROM_START}}); with the fix, 0 of 3000 seeds.
*Affected*: main, camel-4.23.x and camel-4.22.x (same code, both signals);
camel-4.18.x and camel-4.14.x have signal 1 and signal 2 without the
CAMEL-24154 refresh (read, not run).
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)