• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / a3559f4b-2f90-42be-9d4f-5d7bc5ed863a

19 Feb 2025 02:48PM UTC coverage: 86.859% (-0.04%) from 86.896%
a3559f4b-2f90-42be-9d4f-5d7bc5ed863a

push

circleci

web-flow
[ESM] Add backoff between Stream Poller retries (#12281)

11 of 11 new or added lines in 1 file covered. (100.0%)

53 existing lines in 15 files now uncovered.

61551 of 70863 relevant lines covered (86.86%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.04
/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py
1
import json
1✔
2
import logging
1✔
3
import threading
1✔
4
from abc import abstractmethod
1✔
5
from datetime import datetime
1✔
6
from typing import Iterator
1✔
7

8
from botocore.client import BaseClient
1✔
9
from botocore.exceptions import ClientError
1✔
10

11
from localstack.aws.api.pipes import (
1✔
12
    OnPartialBatchItemFailureStreams,
13
)
14
from localstack.services.lambda_.event_source_mapping.event_processor import (
1✔
15
    BatchFailureError,
16
    CustomerInvocationError,
17
    EventProcessor,
18
    PartialBatchFailureError,
19
    PipeInternalError,
20
)
21
from localstack.services.lambda_.event_source_mapping.pipe_utils import (
1✔
22
    get_current_time,
23
    get_datetime_from_timestamp,
24
    get_internal_client,
25
)
26
from localstack.services.lambda_.event_source_mapping.pollers.poller import (
1✔
27
    Poller,
28
    get_batch_item_failures,
29
)
30
from localstack.services.lambda_.event_source_mapping.pollers.sqs_poller import get_queue_url
1✔
31
from localstack.utils.aws.arns import parse_arn, s3_bucket_name
1✔
32
from localstack.utils.backoff import ExponentialBackoff
1✔
33
from localstack.utils.strings import long_uid
1✔
34

35
LOG = logging.getLogger(__name__)
1✔
36

37

38
# TODO: fix this poller to support resharding
39
#   https://docs.aws.amazon.com/streams/latest/dev/kinesis-using-sdk-java-resharding.html
40
class StreamPoller(Poller):
1✔
41
    # Mapping of shard id => shard iterator
42
    shards: dict[str, str]
1✔
43
    # Iterator for round-robin polling from different shards because a batch cannot contain events from different shards
44
    # This is a workaround for not handling shards in parallel.
45
    iterator_over_shards: Iterator[tuple[str, str]] | None
1✔
46
    # ESM UUID is needed in failure processing to form s3 failure destination object key
47
    esm_uuid: str | None
1✔
48

49
    # The ARN of the processor (e.g., Pipe ARN)
50
    partner_resource_arn: str | None
1✔
51

52
    # Used for backing-off between retries and breaking the retry loop
53
    _is_shutdown: threading.Event
1✔
54

55
    def __init__(
1✔
56
        self,
57
        source_arn: str,
58
        source_parameters: dict | None = None,
59
        source_client: BaseClient | None = None,
60
        processor: EventProcessor | None = None,
61
        partner_resource_arn: str | None = None,
62
        esm_uuid: str | None = None,
63
    ):
64
        super().__init__(source_arn, source_parameters, source_client, processor)
1✔
65
        self.partner_resource_arn = partner_resource_arn
1✔
66
        self.esm_uuid = esm_uuid
1✔
67
        self.shards = {}
1✔
68
        self.iterator_over_shards = None
1✔
69

70
        self._is_shutdown = threading.Event()
1✔
71

72
    @abstractmethod
1✔
73
    def transform_into_events(self, records: list[dict], shard_id) -> list[dict]:
1✔
UNCOV
74
        pass
×
75

76
    @property
1✔
77
    @abstractmethod
1✔
78
    def stream_parameters(self) -> dict:
1✔
UNCOV
79
        pass
×
80

81
    @abstractmethod
1✔
82
    def initialize_shards(self) -> dict[str, str]:
1✔
83
        """Returns a shard dict mapping from shard id -> shard iterator
84
        The implementations for Kinesis and DynamoDB are similar but differ in various ways:
85
        * Kinesis uses "StreamARN" and DynamoDB uses "StreamArn" as source parameter
86
        * Kinesis uses "StreamStatus.ACTIVE" and DynamoDB uses "StreamStatus.ENABLED"
87
        * Only Kinesis supports the additional StartingPosition called "AT_TIMESTAMP" using "StartingPositionTimestamp"
88
        """
UNCOV
89
        pass
×
90

91
    @abstractmethod
1✔
92
    def stream_arn_param(self) -> dict:
1✔
93
        """Returns a dict of the correct key/value pair for the stream arn used in GetRecords.
94
        Either StreamARN for Kinesis or {} for DynamoDB (unsupported)"""
UNCOV
95
        pass
×
96

97
    @abstractmethod
1✔
98
    def failure_payload_details_field_name(self) -> str:
1✔
UNCOV
99
        pass
×
100

101
    @abstractmethod
1✔
102
    def get_approximate_arrival_time(self, record: dict) -> float:
1✔
UNCOV
103
        pass
×
104

105
    @abstractmethod
1✔
106
    def format_datetime(self, time: datetime) -> str:
1✔
107
        """Formats a datetime in the correct format for DynamoDB (with ms) or Kinesis (without ms)"""
UNCOV
108
        pass
×
109

110
    @abstractmethod
1✔
111
    def get_sequence_number(self, record: dict) -> str:
1✔
UNCOV
112
        pass
×
113

114
    def close(self):
1✔
115
        self._is_shutdown.set()
1✔
116

117
    def pre_filter(self, events: list[dict]) -> list[dict]:
1✔
118
        return events
1✔
119

120
    def post_filter(self, events: list[dict]) -> list[dict]:
1✔
121
        return events
1✔
122

123
    def poll_events(self):
1✔
124
        """Generalized poller for streams such as Kinesis or DynamoDB
125
        Examples of Kinesis consumers:
126
        * StackOverflow: https://stackoverflow.com/a/22403036/6875981
127
        * AWS Sample: https://github.com/aws-samples/kinesis-poster-worker/blob/master/worker.py
128
        Examples of DynamoDB consumers:
129
        * Blogpost: https://www.tecracer.com/blog/2022/05/getting-a-near-real-time-view-of-a-dynamodb-stream-with-python.html
130
        """
131
        # TODO: consider potential shard iterator timeout after 300 seconds (likely not relevant with short-polling):
132
        #   https://docs.aws.amazon.com/streams/latest/dev/troubleshooting-consumers.html#shard-iterator-expires-unexpectedly
133
        #  Does this happen if no records are received for 300 seconds?
134
        if not self.shards:
1✔
135
            self.shards = self.initialize_shards()
1✔
136

137
        # TODO: improve efficiency because this currently limits the throughput to at most batch size per poll interval
138
        # Handle shards round-robin. Re-initialize current shard iterator once all shards are handled.
139
        if self.iterator_over_shards is None:
1✔
140
            self.iterator_over_shards = iter(self.shards.items())
1✔
141

142
        current_shard_tuple = next(self.iterator_over_shards, None)
1✔
143
        if not current_shard_tuple:
1✔
144
            self.iterator_over_shards = iter(self.shards.items())
1✔
145
            current_shard_tuple = next(self.iterator_over_shards, None)
1✔
146

147
        try:
1✔
148
            self.poll_events_from_shard(*current_shard_tuple)
1✔
149
        # TODO: implement exponential back-off for errors in general
150
        except PipeInternalError:
1✔
151
            # TODO: standardize logging
152
            # Ignore and wait for the next polling interval, which will do retry
UNCOV
153
            pass
×
154

155
    def poll_events_from_shard(self, shard_id: str, shard_iterator: str):
1✔
156
        abort_condition = None
1✔
157
        get_records_response = self.get_records(shard_iterator)
1✔
158
        records = get_records_response["Records"]
1✔
159
        polled_events = self.transform_into_events(records, shard_id)
1✔
160
        # Check MaximumRecordAgeInSeconds
161
        if maximum_record_age_in_seconds := self.stream_parameters.get("MaximumRecordAgeInSeconds"):
1✔
UNCOV
162
            arrival_timestamp_of_last_event = polled_events[-1]["approximateArrivalTimestamp"]
×
163
            now = get_current_time().timestamp()
×
UNCOV
164
            record_age_in_seconds = now - arrival_timestamp_of_last_event
×
UNCOV
165
            if record_age_in_seconds > maximum_record_age_in_seconds:
×
UNCOV
166
                abort_condition = "RecordAgeExpired"
×
167

168
        # TODO: implement format detection behavior (e.g., for JSON body):
169
        #  https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html
170
        #  Check whether we need poller-specific filter-preprocessing here without modifying the actual event!
171
        # convert to json for filtering (HACK for fixing parity with v1 and getting regression tests passing)
172
        # localstack.services.lambda_.event_source_listeners.kinesis_event_source_listener.KinesisEventSourceListener._filter_records
173
        # TODO: explore better abstraction for the entire filtering, including the set_data and get_data remapping
174
        #  We need better clarify which transformations happen before and after filtering -> fix missing test coverage
175
        parsed_events = self.pre_filter(polled_events)
1✔
176
        # TODO: advance iterator past matching events!
177
        #  We need to checkpoint the sequence number for each shard and then advance the shard iterator using
178
        #  GetShardIterator with a given sequence number
179
        #  https://docs.aws.amazon.com/kinesis/latest/APIReference/API_GetShardIterator.html
180
        #  Failing to do so kinda blocks the stream resulting in very high latency.
181
        matching_events = self.filter_events(parsed_events)
1✔
182
        matching_events_post_filter = self.post_filter(matching_events)
1✔
183

184
        # TODO: implement MaximumBatchingWindowInSeconds flush condition (before or after filter?)
185
        # Don't trigger upon empty events
186
        if len(matching_events_post_filter) == 0:
1✔
187
            # Update shard iterator if no records match the filter
188
            self.shards[shard_id] = get_records_response["NextShardIterator"]
1✔
189
            return
1✔
190
        events = self.add_source_metadata(matching_events_post_filter)
1✔
191
        LOG.debug("Polled %d events from %s in shard %s", len(events), self.source_arn, shard_id)
1✔
192
        # TODO: A retry should probably re-trigger fetching the record from the stream again?!
193
        #  -> This could be tested by setting a high retry number, using a long pipe execution, and a relatively
194
        #  short record expiration age at the source. Check what happens if the record expires at the source.
195
        #  A potential implementation could use checkpointing based on the iterator position (within shard scope)
196
        # TODO: handle partial batch failure (see poller.py:parse_batch_item_failures)
197
        # TODO: think about how to avoid starvation of other shards if one shard runs into infinite retries
198
        attempts = 0
1✔
199
        error_payload = {}
1✔
200

201
        boff = ExponentialBackoff(max_retries=attempts)
1✔
202
        while (
1✔
203
            not abort_condition
204
            and not self.max_retries_exceeded(attempts)
205
            and not self._is_shutdown.is_set()
206
        ):
207
            try:
1✔
208
                if attempts > 0:
1✔
209
                    # TODO: Should we always backoff (with jitter) before processing since we may not want multiple pollers
210
                    # all starting up and polling simultaneously
211
                    # For example: 500 persisted ESMs starting up and requesting concurrently could flood gateway
212
                    self._is_shutdown.wait(boff.next_backoff())
1✔
213

214
                self.processor.process_events_batch(events)
1✔
215
                boff.reset()
1✔
216

217
                # Update shard iterator if execution is successful
218
                self.shards[shard_id] = get_records_response["NextShardIterator"]
1✔
219
                return
1✔
220
            except PartialBatchFailureError as ex:
1✔
221
                # TODO: add tests for partial batch failure scenarios
222
                if (
1✔
223
                    self.stream_parameters.get("OnPartialBatchItemFailure")
224
                    == OnPartialBatchItemFailureStreams.AUTOMATIC_BISECT
225
                ):
226
                    # TODO: implement and test splitting batches in half until batch size 1
227
                    #  https://docs.aws.amazon.com/eventbridge/latest/pipes-reference/API_PipeSourceKinesisStreamParameters.html
UNCOV
228
                    LOG.warning(
×
229
                        "AUTOMATIC_BISECT upon partial batch item failure is not yet implemented. Retrying the entire batch."
230
                    )
231
                error_payload = ex.error
1✔
232

233
                # Extract all sequence numbers from events in batch. This allows us to fail the whole batch if
234
                # an unknown itemidentifier is returned.
235
                batch_sequence_numbers = {
1✔
236
                    self.get_sequence_number(event) for event in matching_events
237
                }
238

239
                # If the batchItemFailures array contains multiple items, Lambda uses the record with the lowest sequence number as the checkpoint.
240
                # Lambda then retries all records starting from that checkpoint.
241
                failed_sequence_ids: list[int] | None = get_batch_item_failures(
1✔
242
                    ex.partial_failure_payload, batch_sequence_numbers
243
                )
244

245
                # If None is returned, consider the entire batch a failure.
246
                if failed_sequence_ids is None:
1✔
247
                    continue
1✔
248

249
                # This shouldn't be possible since a PartialBatchFailureError was raised
250
                if len(failed_sequence_ids) == 0:
1✔
UNCOV
251
                    assert failed_sequence_ids, (
×
252
                        "Invalid state encountered: PartialBatchFailureError raised but no batch item failures found."
253
                    )
254

255
                lowest_sequence_id: str = min(failed_sequence_ids, key=int)
1✔
256

257
                # Discard all successful events and re-process from sequence number of failed event
258
                _, events = self.bisect_events(lowest_sequence_id, events)
1✔
259
            except (BatchFailureError, Exception) as ex:
1✔
260
                if isinstance(ex, BatchFailureError):
1✔
261
                    error_payload = ex.error
1✔
262

263
                # FIXME partner_resource_arn is not defined in ESM
264
                LOG.debug(
1✔
265
                    "Attempt %d failed while processing %s with events: %s",
266
                    attempts,
267
                    self.partner_resource_arn or self.source_arn,
268
                    events,
269
                )
270
            finally:
271
                # Retry polling until the record expires at the source
272
                attempts += 1
1✔
273

274
        # Send failed events to potential DLQ
275
        abort_condition = abort_condition or "RetryAttemptsExhausted"
1✔
276
        failure_context = self.processor.generate_event_failure_context(
1✔
277
            abort_condition=abort_condition,
278
            error=error_payload,
279
            attempts_count=attempts,
280
            partner_resource_arn=self.partner_resource_arn,
281
        )
282
        self.send_events_to_dlq(shard_id, events, context=failure_context)
1✔
283
        # Update shard iterator if the execution failed but the events are sent to a DLQ
284
        self.shards[shard_id] = get_records_response["NextShardIterator"]
1✔
285

286
    def get_records(self, shard_iterator: str) -> dict:
1✔
287
        """Returns a GetRecordsOutput from the GetRecords endpoint of streaming services such as Kinesis or DynamoDB"""
288
        try:
1✔
289
            get_records_response = self.source_client.get_records(
1✔
290
                # TODO: add test for cross-account scenario
291
                # Differs for Kinesis and DynamoDB but required for cross-account scenario
292
                **self.stream_arn_param(),
293
                ShardIterator=shard_iterator,
294
                Limit=self.stream_parameters["BatchSize"],
295
            )
296
            return get_records_response
1✔
297
        # TODO: test iterator expired with conditional error scenario (requires failure destinations)
298
        except self.source_client.exceptions.ExpiredIteratorException as e:
1✔
UNCOV
299
            LOG.debug(
×
300
                "Shard iterator %s expired for stream %s, re-initializing shards",
301
                shard_iterator,
302
                self.source_arn,
303
            )
304
            # TODO: test TRIM_HORIZON and AT_TIMESTAMP scenarios for this case. We don't want to start from scratch and
305
            #  might need to think about checkpointing here.
UNCOV
306
            self.shards = self.initialize_shards()
×
UNCOV
307
            raise PipeInternalError from e
×
308
        except ClientError as e:
1✔
309
            if "AccessDeniedException" in str(e):
1✔
UNCOV
310
                LOG.warning(
×
311
                    "Insufficient permissions to get records from stream %s: %s",
312
                    self.source_arn,
313
                    e,
314
                )
UNCOV
315
                raise CustomerInvocationError from e
×
316
            elif "ResourceNotFoundException" in str(e):
1✔
317
                # FIXME: The 'Invalid ShardId in ShardIterator' error is returned by DynamoDB-local. Unsure when/why this is returned.
318
                if "Invalid ShardId in ShardIterator" in str(e):
1✔
UNCOV
319
                    LOG.warning(
×
320
                        "Invalid ShardId in ShardIterator for %s. Re-initializing shards.",
321
                        self.source_arn,
322
                    )
323
                    self.initialize_shards()
×
324
                else:
325
                    LOG.warning(
1✔
326
                        "Source stream %s does not exist: %s",
327
                        self.source_arn,
328
                        e,
329
                    )
330
                    raise CustomerInvocationError from e
1✔
331
            elif "TrimmedDataAccessException" in str(e):
×
UNCOV
332
                LOG.debug(
×
333
                    "Attempted to iterate over trimmed record or expired shard iterator %s for stream %s, re-initializing shards",
334
                    shard_iterator,
335
                    self.source_arn,
336
                )
UNCOV
337
                self.initialize_shards()
×
338
            else:
339
                LOG.debug("ClientError during get_records for stream %s: %s", self.source_arn, e)
×
UNCOV
340
                raise PipeInternalError from e
×
341

342
    def send_events_to_dlq(self, shard_id, events, context) -> None:
1✔
343
        dlq_arn = self.stream_parameters.get("DeadLetterConfig", {}).get("Arn")
1✔
344
        if dlq_arn:
1✔
345
            failure_timstamp = get_current_time()
1✔
346
            dlq_event = self.create_dlq_event(shard_id, events, context, failure_timstamp)
1✔
347
            # Send DLQ event to DLQ target
348
            parsed_arn = parse_arn(dlq_arn)
1✔
349
            service = parsed_arn["service"]
1✔
350
            # TODO: use a sender instance here, likely inject via DI into poller (what if it updates?)
351
            if service == "sqs":
1✔
352
                # TODO: inject and cache SQS client using proper IAM role (supports cross-account operations)
353
                sqs_client = get_internal_client(dlq_arn)
1✔
354
                # TODO: check if the DLQ exists
355
                dlq_url = get_queue_url(dlq_arn)
1✔
356
                # TODO: validate no FIFO queue because they are unsupported
357
                sqs_client.send_message(QueueUrl=dlq_url, MessageBody=json.dumps(dlq_event))
1✔
358
            elif service == "sns":
1✔
359
                sns_client = get_internal_client(dlq_arn)
1✔
360
                sns_client.publish(TopicArn=dlq_arn, Message=json.dumps(dlq_event))
1✔
361
            elif service == "s3":
1✔
362
                s3_client = get_internal_client(dlq_arn)
1✔
363
                dlq_event_with_payload = {
1✔
364
                    **dlq_event,
365
                    "payload": {
366
                        "Records": events,
367
                    },
368
                }
369
                s3_client.put_object(
1✔
370
                    Bucket=s3_bucket_name(dlq_arn),
371
                    Key=get_failure_s3_object_key(self.esm_uuid, shard_id, failure_timstamp),
372
                    Body=json.dumps(dlq_event_with_payload),
373
                )
374
            else:
UNCOV
375
                LOG.warning("Unsupported DLQ service %s", service)
×
376

377
    def create_dlq_event(
1✔
378
        self, shard_id: str, events: list[dict], context: dict, failure_timestamp: datetime
379
    ) -> dict:
380
        first_record = events[0]
1✔
381
        first_record_arrival = get_datetime_from_timestamp(
1✔
382
            self.get_approximate_arrival_time(first_record)
383
        )
384

385
        last_record = events[-1]
1✔
386
        last_record_arrival = get_datetime_from_timestamp(
1✔
387
            self.get_approximate_arrival_time(last_record)
388
        )
389
        return {
1✔
390
            **context,
391
            self.failure_payload_details_field_name(): {
392
                "approximateArrivalOfFirstRecord": self.format_datetime(first_record_arrival),
393
                "approximateArrivalOfLastRecord": self.format_datetime(last_record_arrival),
394
                "batchSize": len(events),
395
                "endSequenceNumber": self.get_sequence_number(last_record),
396
                "shardId": shard_id,
397
                "startSequenceNumber": self.get_sequence_number(first_record),
398
                "streamArn": self.source_arn,
399
            },
400
            "timestamp": failure_timestamp.isoformat(timespec="milliseconds").replace(
401
                "+00:00", "Z"
402
            ),
403
            "version": "1.0",
404
        }
405

406
    def max_retries_exceeded(self, attempts: int) -> bool:
1✔
407
        maximum_retry_attempts = self.stream_parameters.get("MaximumRetryAttempts", -1)
1✔
408
        # Infinite retries until the source expires
409
        if maximum_retry_attempts == -1:
1✔
410
            return False
1✔
411
        return attempts > maximum_retry_attempts
1✔
412

413
    def bisect_events(
1✔
414
        self, sequence_number: str, events: list[dict]
415
    ) -> tuple[list[dict], list[dict]]:
416
        """Splits list of events in two, where a sequence number equals a passed parameter `sequence_number`.
417
        This is used for:
418
          - `ReportBatchItemFailures`: Discarding events in a batch following a failure when is set.
419
          - `BisectBatchOnFunctionError`: Used to split a failed batch in two when doing a retry (not implemented)."""
420
        for i, event in enumerate(events):
1✔
421
            if self.get_sequence_number(event) == sequence_number:
1✔
422
                return events[:i], events[i:]
1✔
423

UNCOV
424
        return events, []
×
425

426

427
def get_failure_s3_object_key(esm_uuid: str, shard_id: str, failure_datetime: datetime) -> str:
1✔
428
    """
429
    From https://docs.aws.amazon.com/lambda/latest/dg/kinesis-on-failure-destination.html:
430

431
    The S3 object containing the invocation record uses the following naming convention:
432
    aws/lambda/<ESM-UUID>/<shardID>/YYYY/MM/DD/YYYY-MM-DDTHH.MM.SS-<Random UUID>
433

434
    :return: Key for s3 object that invocation failure record will be put to
435
    """
436
    timestamp = failure_datetime.strftime("%Y-%m-%dT%H.%M.%S")
1✔
437
    year_month_day = failure_datetime.strftime("%Y/%m/%d")
1✔
438
    random_uuid = long_uid()
1✔
439
    return f"aws/lambda/{esm_uuid}/{shard_id}/{year_month_day}/{timestamp}-{random_uuid}"
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc