Skip to content

Commit 28ac2f3

Browse files
jentykclaude
andcommitted
test(e2e): assert stream() holds memory constant at volume
The streaming e2e called list() on the export, which buffers every record and so never exercised the property it appeared to cover: an implementation that materialised the whole body would have passed it unchanged. Add sync and async coverage that streams 20,000 operations-scoped catalog items and asserts on the tracemalloc allocation peak, taken over three whole reads within a run: a 2,000-record export, the 20,000-record export, and the same export buffered with list(). Ten times the records must not cost materially more memory, and buffering must cost far more, which is what shows the measurement can still see buffering at this volume rather than passing vacuously on a dataset too small to matter. Each measured window spans a read from the request opening to the body being exhausted, because a buffering implementation fills its list before yielding its first record and a window starting mid-stream would measure only the walk over an already-materialised list. Verified against a deliberately buffering stream(), which fails both assertions. The thresholds are ratios between peaks measured in the same run, not absolute byte counts, so they need no per-environment calibration. tracemalloc counts Python allocations rather than process resident size, so the result does not move with allocator, kernel or GC timing. MPT-24260 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ec5e43d commit 28ac2f3

6 files changed

Lines changed: 220 additions & 1 deletion

File tree

‎docs/e2e_tests.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ tests/
1515
├── catalog/
1616
├── commerce/
1717
├── helpdesk/
18-
└── notifications/
18+
├── notifications/
19+
└── streaming/
1920
```
2021

2122
## Running Tests
@@ -28,6 +29,26 @@ make test args="tests/e2e/catalog" # run a subset of E2E tests
2829
E2E suites use `pytest` markers and live API credentials, so they run outside the default
2930
`make test` invocation.
3031

32+
## Streaming Memory Coverage
33+
34+
`tests/e2e/streaming/` asserts the property `stream()` exists for: memory stays bounded
35+
however many records an export carries. It is the one suite whose result depends on the
36+
size of the live dataset, so it carries requirements the rest of `tests/e2e/` does not.
37+
38+
It streams operations-scoped `catalog.items`, which holds well over the 20,000 records the
39+
coverage exports. The measurement is a `tracemalloc` allocation peak — Python allocations
40+
rather than process resident size, so it does not move with allocator or kernel behaviour —
41+
taken over three whole reads: a 2,000-record export, a 20,000-record export, and the same
42+
20,000-record export buffered with `list()`. Ten times the records must not cost materially
43+
more memory, and buffering the same export must cost far more, which is what shows the
44+
measurement is sensitive enough to see buffering reintroduced.
45+
46+
The comparison is against the peaks measured in the same run rather than an absolute byte
47+
threshold, so it does not need recalibrating per environment. If the environment holds fewer
48+
records than the coverage exports, the fixture fails with that count rather than passing on a
49+
dataset too small to mean anything. `tests/e2e/streaming/memory_probe.py` documents the
50+
protocol and the reasoning behind each constant.
51+
3152
## Environment Variables
3253

3354
| Variable | Description |

‎tests/e2e/streaming/__init__.py‎

Whitespace-only changes.

‎tests/e2e/streaming/conftest.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
from tests.e2e.streaming.memory_probe import VOLUME_RECORDS
4+
5+
6+
def _require_volume(page):
7+
total = page.meta.pagination.total if page.meta else 0
8+
if total < VOLUME_RECORDS:
9+
pytest.fail(
10+
f"Catalog items hold {total} records on this environment, fewer than the "
11+
f"{VOLUME_RECORDS} this coverage streams. The constant-memory assertion is "
12+
"only meaningful at volume, so this is a data problem, not a client failure."
13+
)
14+
15+
16+
@pytest.fixture
17+
def large_collection(mpt_ops):
18+
service = mpt_ops.catalog.items
19+
_require_volume(service.fetch_page(limit=1))
20+
return service
21+
22+
23+
@pytest.fixture
24+
async def async_large_collection(async_mpt_ops):
25+
service = async_mpt_ops.catalog.items
26+
_require_volume(await service.fetch_page(limit=1))
27+
return service
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Measurement protocol behind the constant-memory streaming coverage.
2+
3+
`stream()` claims memory stays bounded no matter how many records the export carries.
4+
Proving that needs two comparisons on the same live data, which is what
5+
`profile_streaming_memory` collects in one pass:
6+
7+
- the allocation peak of a small export against the peak of an export ten times longer,
8+
so a peak that scales with the record count fails;
9+
- the peak of the streamed read against the peak of buffering the same export, so the
10+
measurement is shown to be sensitive enough to see buffering at this volume.
11+
12+
Every measured window covers a whole read, from the request being opened to the body being
13+
exhausted. That matters: an implementation that buffers does so before it yields its first
14+
record, so a window that started mid-stream would measure only the walk over an
15+
already-materialised list and see nothing wrong.
16+
17+
Peaks come from `tracemalloc`, which counts Python allocations rather than process resident
18+
size, so the numbers do not move with allocator or kernel behaviour. Every peak is reported
19+
net of the memory already live when its window opens, and each window starts with a
20+
collection, so an object released by an earlier read is never charged to a later one.
21+
"""
22+
23+
import gc
24+
import tracemalloc
25+
from collections.abc import Awaitable, Callable, Iterator
26+
from contextlib import contextmanager
27+
from dataclasses import dataclass
28+
from typing import Any
29+
30+
# Records exported before the first measured window, so connection setup, lazy imports and
31+
# the first read buffers are paid for outside every comparison. This is a separate read,
32+
# not a prologue of a measured one, so nothing can hide in it.
33+
WARMUP_RECORDS = 500
34+
# Records exported by the short read that the volume read is compared against.
35+
SAMPLE_RECORDS = 2000
36+
# Records exported by the volume read: ten times the short read, and enough that buffering
37+
# the same export costs tens of megabytes.
38+
VOLUME_RECORDS = 20000
39+
# How much the volume read's peak may exceed the short read's. Ten times the records must
40+
# not cost materially more memory; a buffering read lands an order of magnitude above this.
41+
MAX_PEAK_GROWTH = 3.0
42+
# How much more buffering the export must peak than streaming it. Guards against a vacuous
43+
# pass: on a dataset too small to matter, the two reads would cost about the same.
44+
MIN_BUFFERING_CONTRAST = 20.0
45+
46+
# One traced frame per allocation: the call site is never reported, so tracing costs less.
47+
TRACE_FRAMES = 1
48+
49+
50+
@dataclass(frozen=True)
51+
class PeakReading:
52+
records: int
53+
peak_bytes: int
54+
55+
56+
@dataclass(frozen=True)
57+
class StreamingMemoryProfile:
58+
sample: PeakReading
59+
volume: PeakReading
60+
buffered: PeakReading
61+
62+
63+
@contextmanager
64+
def tracing_allocations() -> Iterator[None]:
65+
gc.collect()
66+
tracemalloc.start(TRACE_FRAMES)
67+
try:
68+
yield
69+
finally:
70+
tracemalloc.stop()
71+
72+
73+
def measure_peak(read: Callable[[], int]) -> PeakReading:
74+
gc.collect()
75+
tracemalloc.reset_peak()
76+
baseline_bytes = tracemalloc.get_traced_memory()[0]
77+
records = read()
78+
peak_bytes = tracemalloc.get_traced_memory()[1] - baseline_bytes
79+
return PeakReading(records=records, peak_bytes=peak_bytes)
80+
81+
82+
async def measure_peak_async(read: Callable[[], Awaitable[int]]) -> PeakReading:
83+
gc.collect()
84+
tracemalloc.reset_peak()
85+
baseline_bytes = tracemalloc.get_traced_memory()[0]
86+
records = await read()
87+
peak_bytes = tracemalloc.get_traced_memory()[1] - baseline_bytes
88+
return PeakReading(records=records, peak_bytes=peak_bytes)
89+
90+
91+
def drain_stream(service: Any, limit: int) -> int:
92+
"""Stream `limit` records, retaining none.
93+
94+
The stream is consumed to exhaustion, which is what triggers its completeness check.
95+
"""
96+
return sum(1 for _ in service.stream(limit=limit))
97+
98+
99+
async def drain_stream_async(service: Any, limit: int) -> int:
100+
"""Stream `limit` records, retaining none.
101+
102+
The stream is consumed to exhaustion, which is what triggers its completeness check.
103+
"""
104+
# WPS519 would have the records collected and counted, which is the buffering this
105+
# measurement exists to rule out.
106+
consumed = 0
107+
async for _ in service.stream(limit=limit): # noqa: WPS519
108+
consumed += 1
109+
return consumed
110+
111+
112+
def buffer_export(service: Any) -> int:
113+
"""Materialise a whole export, the buffering read the streamed read is compared with."""
114+
return len(list(service.stream(limit=VOLUME_RECORDS)))
115+
116+
117+
async def buffer_export_async(service: Any) -> int:
118+
"""Materialise a whole export, the buffering read the streamed read is compared with."""
119+
return len([record async for record in service.stream(limit=VOLUME_RECORDS)])
120+
121+
122+
def profile_streaming_memory(service: Any) -> StreamingMemoryProfile:
123+
with tracing_allocations():
124+
drain_stream(service, WARMUP_RECORDS)
125+
sample = measure_peak(lambda: drain_stream(service, SAMPLE_RECORDS))
126+
volume = measure_peak(lambda: drain_stream(service, VOLUME_RECORDS))
127+
buffered = measure_peak(lambda: buffer_export(service))
128+
return StreamingMemoryProfile(sample=sample, volume=volume, buffered=buffered)
129+
130+
131+
async def profile_async_streaming_memory(service: Any) -> StreamingMemoryProfile:
132+
with tracing_allocations():
133+
await drain_stream_async(service, WARMUP_RECORDS)
134+
sample = await measure_peak_async(lambda: drain_stream_async(service, SAMPLE_RECORDS))
135+
volume = await measure_peak_async(lambda: drain_stream_async(service, VOLUME_RECORDS))
136+
buffered = await measure_peak_async(lambda: buffer_export_async(service))
137+
return StreamingMemoryProfile(sample=sample, volume=volume, buffered=buffered)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from tests.e2e.streaming.memory_probe import (
2+
MAX_PEAK_GROWTH,
3+
MIN_BUFFERING_CONTRAST,
4+
SAMPLE_RECORDS,
5+
VOLUME_RECORDS,
6+
profile_async_streaming_memory,
7+
)
8+
9+
10+
async def test_stream_at_volume_keeps_memory_bounded(async_large_collection):
11+
result = await profile_async_streaming_memory(async_large_collection)
12+
13+
assert result.sample.records == SAMPLE_RECORDS
14+
assert result.volume.records == VOLUME_RECORDS
15+
assert result.buffered.records == VOLUME_RECORDS
16+
assert result.volume.peak_bytes <= result.sample.peak_bytes * MAX_PEAK_GROWTH
17+
assert result.buffered.peak_bytes >= result.volume.peak_bytes * MIN_BUFFERING_CONTRAST
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from tests.e2e.streaming.memory_probe import (
2+
MAX_PEAK_GROWTH,
3+
MIN_BUFFERING_CONTRAST,
4+
SAMPLE_RECORDS,
5+
VOLUME_RECORDS,
6+
profile_streaming_memory,
7+
)
8+
9+
10+
def test_stream_at_volume_keeps_memory_bounded(large_collection):
11+
result = profile_streaming_memory(large_collection)
12+
13+
assert result.sample.records == SAMPLE_RECORDS
14+
assert result.volume.records == VOLUME_RECORDS
15+
assert result.buffered.records == VOLUME_RECORDS
16+
assert result.volume.peak_bytes <= result.sample.peak_bytes * MAX_PEAK_GROWTH
17+
assert result.buffered.peak_bytes >= result.volume.peak_bytes * MIN_BUFFERING_CONTRAST

0 commit comments

Comments
 (0)