Skip to content

S3Object streaming: iterating a stream corrupts subsequent seek()/read() by silently returning the wrong bytes #8388

Description

@Adityaj0

Expected Behaviour

Consuming an S3Object/_S3SeekableIO stream via the iterator protocol (for line in s3_object: ... or next(s3_object)) should keep the stream's position tracking accurate, so any subsequent seek() or read() operates from the correct byte offset.

Current Behaviour

_S3SeekableIO.__next__ and __iter__ bypass position tracking entirely, so any seek() after iterating the stream computes the wrong S3 byte range, and the following read() silently returns the wrong slice of the object — no exception is raised, the data is just wrong (skipped or duplicated bytes).

Root cause

aws_lambda_powertools/utilities/streaming/_s3_seekable_io.py:

Every other read path in this class carefully advances self._position by the number of bytes actually consumed, specifically so seek() can compute the correct absolute S3 byte offset:

def read(self, size: int | None = -1) -> bytes:
    size = None if size == -1 else size
    data = self.raw_stream.read(size)
    if data is not None:
        self._position += len(data)
    return data

def readline(self, size: int | None = None) -> bytes:
    data = self.raw_stream.readline(size)
    self._position += len(data)
    return data

But __next__/__iter__ don't do this bookkeeping at all:

def __next__(self):
    return self.raw_stream.__next__()

def __iter__(self):
    return self.raw_stream.__iter__()

They hand iteration control directly to botocore's StreamingBody, whose own iterator reads chunks straight off the underlying stream and tracks its own internal counter — one _S3SeekableIO never looks at. self._position stays at whatever it was before iteration began.

seek() uses self._position as ground truth to build the next Range header on raw_stream access:

@property
def raw_stream(self) -> PowertoolsStreamingBody:
    if self._raw_stream is None:
        range_header = f"bytes={self._position}-"
        ...

So after iterating N bytes and then seeking, the resulting Range header is computed from the stale pre-iteration position instead of the true current position — the reopened stream starts at the wrong byte offset.

Concrete reproduction

s3obj = S3Object(bucket=bucket, key=key)

for line in s3obj:          # consumes bytes via __iter__/__next__; self._position never advances
    ...

s3obj.seek(5, io.SEEK_CUR)  # computed as 0 + 5, not (actual bytes consumed) + 5
s3obj.read(...)             # silently reads the wrong slice of the object

Note: this is masked when a GzipTransform/CsvTransform is applied, because GzipFile/TextIOWrapper internally call .read() (which is position-tracked correctly) rather than .__iter__(). The bug is exposed specifically on the raw/untransformed stream's iteration protocol — a fully supported, documented usage (S3Object's class docstring advertises being both seekable and streamable/iterable).

Possible Solution

Route __next__ through the already position-tracked readline(), and have __iter__ return self — matching the standard Python file-iterator protocol. I have a fix + regression tests ready and will open a PR referencing this issue.

Steps to Reproduce

Reproducible via unit test against _S3SeekableIO: iterate a multi-line payload fully, then check tell() — it stays at 0 instead of reflecting bytes consumed. A subsequent seek() + raw_stream access then requests the wrong Range header.

Powertools for AWS Lambda (Python) version

latest (develop, v3.34.0)

AWS Lambda function runtime

3.12

Debugging logs

N/A — logic bug reproducible via unit test, not runtime-log-dependent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions