File tree Expand file tree Collapse file tree
langgraph_plugin/graph_api/streaming Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -22,12 +22,19 @@ async def main() -> None:
2222
2323 # Subscribe to all topics on the workflow's stream and demultiplex on topic.
2424 ws = WorkflowStreamClient .create (client , handle .id )
25+ # Streaming is at-least-once per activity attempt, so a retried node may
26+ # re-publish tokens. Dedupe on the chunk's seq to consume idempotently.
27+ seen_tokens : set [int ] = set ()
2528 async for item in ws .subscribe (
2629 from_offset = 0 ,
2730 result_type = dict ,
2831 poll_cooldown = timedelta (milliseconds = 50 ),
2932 ):
3033 if item .topic == "tokens" :
34+ seq = item .data ["seq" ]
35+ if seq in seen_tokens :
36+ continue # duplicate from a node retry; already consumed.
37+ seen_tokens .add (seq )
3138 print (item .data ["token" ], end = "" , flush = True )
3239 elif item .topic == "progress" :
3340 if item .data .get ("done" ):
Original file line number Diff line number Diff line change @@ -36,11 +36,18 @@ async def outline(state: State) -> dict[str, str]:
3636
3737
3838async def write_story (state : State ) -> dict [str , str ]:
39- """Write the story, emitting each word as a token via the stream writer."""
39+ """Write the story, emitting each word as a token via the stream writer.
40+
41+ Streaming is at-least-once per activity attempt: if this node retries
42+ (transient failure, worker restart) it re-runs from scratch and re-publishes
43+ its writes, so subscribers may see the same token twice. Each chunk therefore
44+ carries a monotonic ``seq`` so consumers can dedupe idempotently. A retry
45+ re-emits the same ``seq`` values, letting the client drop the duplicates.
46+ """
4047 writer = get_stream_writer ()
4148 words = f"{ state ['story' ]} Once upon a time, there was { state ['topic' ]} ." .split ()
42- for word in words :
43- writer ({"token" : word + " " })
49+ for seq , word in enumerate ( words ) :
50+ writer ({"seq" : seq , " token" : word + " " })
4451 return {"story" : " " .join (words )}
4552
4653
Original file line number Diff line number Diff line change @@ -51,10 +51,14 @@ async def test_streaming_graph_api(client: Client) -> None:
5151
5252 result = await handle .result ()
5353
54- # Tokens reassemble into the final story.
54+ # Tokens reassemble into the final story. Streaming is at-least-once per
55+ # activity attempt, so dedupe on seq (keeping first-seen order) before
56+ # reassembling, exactly as an idempotent consumer would.
5557 assert tokens , "expected at least one token"
56- assert all ("token" in t for t in tokens )
57- assembled = "" .join (t ["token" ] for t in tokens ).strip ()
58+ assert all ("seq" in t and "token" in t for t in tokens )
59+ seen : set [int ] = set ()
60+ deduped = [t for t in tokens if not (t ["seq" ] in seen or seen .add (t ["seq" ]))]
61+ assembled = "" .join (t ["token" ] for t in deduped ).strip ()
5862 assert assembled == result
5963
6064 # Workflow-side astream publish: one chunk per node, in order.
You can’t perform that action at this time.
0 commit comments