Skip to content

Commit 9948014

Browse files
brianstrauchclaude
andcommitted
Dedupe streaming tokens on a sequence id
Streaming is at-least-once per activity attempt: a retried node re-runs and re-publishes its writes, so subscribers can see duplicate tokens. Tag each token chunk with a monotonic seq and dedupe on it in the client and test, demonstrating the documented idempotent-consumer pattern instead of implicitly assuming exactly-once delivery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 346b49f commit 9948014

3 files changed

Lines changed: 24 additions & 6 deletions

File tree

‎langgraph_plugin/graph_api/streaming/run_workflow.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff 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"):

‎langgraph_plugin/graph_api/streaming/workflow.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,18 @@ async def outline(state: State) -> dict[str, str]:
3636

3737

3838
async 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

‎tests/langgraph_plugin/streaming_test.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff 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.

0 commit comments

Comments
 (0)