Skip to content

Commit fd16631

Browse files
committed
streaming fix
1 parent 6e91a7f commit fd16631

3 files changed

Lines changed: 64 additions & 9 deletions

File tree

‎.agents/skills/sdk-integrations/SKILL.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,28 @@ Assert on emitted spans (not just provider return values):
263263

264264
For streaming, assert both the provider iterator/async-iterator still works AND the final span has aggregated `output` + stream-specific `metrics`.
265265

266+
### Streaming lifecycle review
267+
268+
Review every exit path, not just full consumption. Check the real provider's
269+
iterator, context-manager, cancellation, and garbage-collection behavior before
270+
choosing a wrapper. A proxy can preserve transport cleanup while still losing
271+
the span's final output and end time.
272+
273+
- Cover exhaustion, provider errors, explicit close/cancel, and context-manager
274+
exit where the provider supports it. Preserve exception and return semantics.
275+
- Cover `break` followed by dropping the last stream reference, and dropping a
276+
stream before consuming any chunks. `break` alone does not close a retained
277+
iterator; do not promise immediate finalization while callers still hold it.
278+
- Use existing recordings for partial-consumption tests. Drop the proxy, force
279+
collection, and assert partial (or empty) output, `metrics.end`, correct
280+
parentage, and no duplicate finalization after explicit close or exhaustion.
281+
- A GC fallback must not retain the stream through its callback or closure.
282+
For async streams, do not run or schedule event-loop work from a finalizer;
283+
finalize trace state and preserve the provider's own cleanup behavior.
284+
- Check that neither iteration nor cleanup leaves the stream span current in
285+
the caller's context. GC is a best-effort fallback, not a substitute for
286+
deterministic cleanup when the caller explicitly closes the stream.
287+
266288
Cassettes live in `integrations/<provider>/cassettes/<version>/` (e.g. `cassettes/latest/`, `cassettes/0.48.0/`). Nox sets `BRAINTRUST_TEST_PACKAGE_VERSION` so cassettes land correctly. Do not add per-test `vcr_cassette_dir` / `cassette_library_dir` fixtures — `integrations/conftest.py` handles it. Re-record only when behavior intentionally changed. Sanitize binary media in both request and response bodies so checked-in cassettes do not retain large base64 payloads.
267289

268290
Confirm the exact session name from `noxfile.py` — don't assume it matches the folder.

‎py/src/braintrust/integrations/google_discoveryengine/test_google_discoveryengine.py‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Real Discovery Engine responses, recorded over REST and gRPC."""
22

3+
import gc
34
import json
45
import os
56
import subprocess
7+
import weakref
68
from contextlib import nullcontext
79
from pathlib import Path
810
from urllib.parse import urlsplit
@@ -398,7 +400,8 @@ def test_patch_scope():
398400

399401

400402
@pytest.mark.vcr("test_answer_query[True].yaml")
401-
def test_stream_close_preserves_parent(memory_logger, credentials, SERVING_CONFIG):
403+
@pytest.mark.parametrize("consume", ["close", "abandon", "unstarted"])
404+
def test_stream_lifecycle(memory_logger, credentials, SERVING_CONFIG, consume):
402405
from braintrust import current_span, start_span
403406
from braintrust.integrations.google_discoveryengine import wrap_google_discoveryengine
404407

@@ -415,10 +418,20 @@ def test_stream_close_preserves_parent(memory_logger, credentials, SERVING_CONFI
415418
retry=None,
416419
)
417420
assert current_span() is parent
418-
next(stream)
421+
chunks = []
422+
if consume != "unstarted":
423+
for chunk in stream:
424+
chunks.append(chunk)
425+
if chunk.answer.answer_text:
426+
break
419427
assert current_span() is parent
420-
stream.close()
421-
stream.close()
428+
if consume == "close":
429+
stream.close()
430+
stream.close()
431+
stream_ref = weakref.ref(stream)
432+
del stream
433+
gc.collect()
434+
assert stream_ref() is None
422435
assert current_span() is parent
423436
spans = memory_logger.pop()
424437
assert len(spans) == 2
@@ -427,7 +440,10 @@ def test_stream_close_preserves_parent(memory_logger, credentials, SERVING_CONFI
427440
)
428441
parent_row = next(span for span in spans if span["span_attributes"]["name"] == "caller")
429442
assert child["span_parents"] == [parent_row["span_id"]]
443+
assert child["output"][0]["message"]["content"] == "".join(chunk.answer.answer_text for chunk in chunks)
430444
assert "end" in child["metrics"]
445+
gc.collect()
446+
assert memory_logger.pop() == []
431447

432448

433449
def test_auto_instrument_subprocess():
@@ -486,7 +502,7 @@ def broken(*args):
486502

487503

488504
@pytest.mark.asyncio
489-
@pytest.mark.parametrize("consume", ["read", "cancel", "aclose"])
505+
@pytest.mark.parametrize("consume", ["read", "cancel", "aclose", "abandon", "unstarted"])
490506
async def test_async_stream_lifecycle(memory_logger, credentials, vcr_cassette_dir, consume, SERVING_CONFIG):
491507
from braintrust import current_span, start_span
492508
from braintrust.integrations.google_discoveryengine import wrap_google_discoveryengine
@@ -521,13 +537,21 @@ async def test_async_stream_lifecycle(memory_logger, credentials, vcr_cassette_d
521537
chunks.append(chunk)
522538
assert current_span() is parent
523539
assert await stream.read() is EOF
524-
else:
525-
chunks.append(await stream.__anext__())
540+
elif consume != "unstarted":
541+
async for chunk in stream:
542+
chunks.append(chunk)
543+
if chunk.answer.answer_text:
544+
break
526545
if consume == "cancel":
527546
assert stream.cancel()
528-
else:
547+
elif consume == "aclose":
529548
await stream.aclose()
530-
assert stream.cancelled()
549+
if consume in ("cancel", "aclose"):
550+
assert stream.cancelled()
551+
stream_ref = weakref.ref(stream)
552+
del stream
553+
gc.collect()
554+
assert stream_ref() is None
531555
assert current_span() is parent
532556
spans = memory_logger.pop()
533557
assert len(spans) == 2
@@ -543,6 +567,8 @@ async def test_async_stream_lifecycle(memory_logger, credentials, vcr_cassette_d
543567
)
544568
assert child["output"][0]["message"]["content"] == expected_text
545569
assert "end" in child["metrics"]
570+
gc.collect()
571+
assert memory_logger.pop() == []
546572
finally:
547573
await client.transport.close()
548574

‎py/src/braintrust/integrations/google_discoveryengine/tracing.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import logging
99
import time
10+
import weakref
1011
from collections.abc import Mapping
1112
from itertools import islice
1213

@@ -195,6 +196,9 @@ class _AnswerStream(ObjectProxy):
195196
def __init__(self, stream, state):
196197
super().__init__(stream)
197198
self._self_state = state
199+
# Retain only trace state, not the proxy/provider stream. GC can finalize
200+
# partial output while the provider handles its own transport cleanup.
201+
weakref.finalize(self, state.finish)
198202
self._self_iterator = iter(stream)
199203

200204
def __iter__(self):
@@ -232,6 +236,9 @@ class _AsyncAnswerStream(ObjectProxy):
232236
def __init__(self, stream, state):
233237
super().__init__(stream)
234238
self._self_state = state
239+
# Retain only trace state, not the proxy/provider stream. GC can finalize
240+
# partial output while the provider handles its own transport cleanup.
241+
weakref.finalize(self, state.finish)
235242
self._self_iterator = None
236243

237244
def __aiter__(self):

0 commit comments

Comments
 (0)