From 44ceff7de5056da870dff440c955349e9de9e78a Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Tue, 28 Apr 2026 21:10:03 -0700 Subject: [PATCH 01/23] Add workflow_streams samples: order_workflow scenario Initial samples directory for temporalio.contrib.workflow_streams, the workflow-hosted durable event stream contrib (experimental, contrib/pubsub branch of sdk-python). The order_workflow scenario covers the basic publisher path: a workflow binds a typed topic in @workflow.init, an activity publishes events via the topic handle, and a starter subscribes with WorkflowStreamClient and prints events as they arrive. Also enables the uv supply-chain cooldown options in the lockfile. --- README.md | 1 + workflow_stream/README.md | 51 +++++++++++ workflow_stream/__init__.py | 0 workflow_stream/activities/__init__.py | 0 .../activities/payment_activity.py | 41 +++++++++ workflow_stream/run_publisher.py | 56 ++++++++++++ workflow_stream/run_worker.py | 27 ++++++ workflow_stream/shared.py | 87 +++++++++++++++++++ workflow_stream/workflows/__init__.py | 0 workflow_stream/workflows/order_workflow.py | 60 +++++++++++++ 10 files changed, 323 insertions(+) create mode 100644 workflow_stream/README.md create mode 100644 workflow_stream/__init__.py create mode 100644 workflow_stream/activities/__init__.py create mode 100644 workflow_stream/activities/payment_activity.py create mode 100644 workflow_stream/run_publisher.py create mode 100644 workflow_stream/run_worker.py create mode 100644 workflow_stream/shared.py create mode 100644 workflow_stream/workflows/__init__.py create mode 100644 workflow_stream/workflows/order_workflow.py diff --git a/README.md b/README.md index d4d6a61b..5a5c937f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Some examples require extra dependencies. See each sample's directory for specif * [patching](patching) - Alter workflows safely with `patch` and `deprecate_patch`. * [polling](polling) - Recommended implementation of an activity that needs to periodically poll an external resource waiting its successful completion. * [prometheus](prometheus) - Configure Prometheus metrics on clients/workers. +* [workflow_stream](workflow_stream) - Workflow-hosted durable event stream via `temporalio.contrib.workflow_stream`. **Experimental — requires the [`contrib/pubsub` branch](https://github.com/temporalio/sdk-python/tree/contrib/pubsub) of sdk-python.** * [pydantic_converter](pydantic_converter) - Data converter for using Pydantic models. * [schedules](schedules) - Demonstrates a Workflow Execution that occurs according to a schedule. * [sentry](sentry) - Report errors to Sentry. diff --git a/workflow_stream/README.md b/workflow_stream/README.md new file mode 100644 index 00000000..3a57a8d7 --- /dev/null +++ b/workflow_stream/README.md @@ -0,0 +1,51 @@ +# Workflow Streams + +> **Experimental.** These samples target the +> `temporalio.contrib.workflow_stream` module on the +> [`contrib/pubsub` branch of sdk-python][branch], which is not yet +> released. To run them locally, install sdk-python from that branch +> (e.g. `uv pip install -e ` after checking out the +> branch). + +[branch]: https://github.com/temporalio/sdk-python/tree/contrib/pubsub + +`temporalio.contrib.workflow_stream` lets a workflow host a durable, +offset-addressed event channel. The workflow holds an append-only log; +external clients (activities, starters, BFFs) publish to topics via +signals and subscribe via long-poll updates. This packages the +boilerplate — batching, offset tracking, topic filtering, continue-as-new +hand-off — into a reusable stream. + +This directory has a minimal end-to-end example: + +* `workflows/order_workflow.py` — a workflow that hosts a + `WorkflowStream` and publishes status events as it processes an order. +* `activities/payment_activity.py` — an activity that publishes + intermediate progress to the stream via + `WorkflowStreamClient.from_activity()`. +* `run_worker.py` — registers the workflow and activity. +* `run_publisher.py` — starts the workflow, then prints subscribed + events as they arrive. + +## Run it + +```bash +# Terminal 1: worker +uv run workflow_stream/run_worker.py + +# Terminal 2: starter + subscriber +uv run workflow_stream/run_publisher.py +``` + +Expected output on the publisher side, with events streaming in as the +workflow progresses: + +``` +[status] received: order=order-1 +[progress] charging card... +[progress] card charged +[status] shipped: order=order-1 +[progress] charge id: charge-order-1 +[status] complete: order=order-1 +workflow result: charge-order-1 +``` diff --git a/workflow_stream/__init__.py b/workflow_stream/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workflow_stream/activities/__init__.py b/workflow_stream/activities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workflow_stream/activities/payment_activity.py b/workflow_stream/activities/payment_activity.py new file mode 100644 index 00000000..f69f8c8d --- /dev/null +++ b/workflow_stream/activities/payment_activity.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from temporalio import activity +from temporalio.contrib.workflow_stream import WorkflowStreamClient + +from workflow_stream.shared import TOPIC_PROGRESS, ProgressEvent + + +@activity.defn +async def charge_card(order_id: str) -> str: + """Pretend to charge a card, publishing progress to the parent workflow. + + `WorkflowStreamClient.from_activity()` reads the parent workflow id + and the Temporal client from the activity context, so this activity + can push events back without any wiring. + + Caveat: each call to ``from_activity()`` creates a fresh client with + a random ``publisher_id``, so dedup does not protect against an + activity retry republishing the same events. For activities that + must be exactly-once on the stream side, derive a stable + ``publisher_id`` from ``activity.info().activity_id`` (this is + invariant across attempts of the same scheduled activity). The + current ``WorkflowStreamClient`` API does not yet expose + ``publisher_id`` on its constructors; this sample accepts + at-most-once-per-attempt semantics. + """ + client = WorkflowStreamClient.from_activity( + batch_interval=timedelta(milliseconds=200) + ) + async with client: + client.publish(TOPIC_PROGRESS, ProgressEvent(message="charging card...")) + await asyncio.sleep(1.0) + client.publish( + TOPIC_PROGRESS, + ProgressEvent(message="card charged"), + force_flush=True, + ) + return f"charge-{order_id}" diff --git a/workflow_stream/run_publisher.py b/workflow_stream/run_publisher.py new file mode 100644 index 00000000..cdfb210d --- /dev/null +++ b/workflow_stream/run_publisher.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +import uuid + +from temporalio.api.common.v1 import Payload +from temporalio.client import Client +from temporalio.contrib.workflow_stream import WorkflowStreamClient + +from workflow_stream.shared import ( + TASK_QUEUE, + TOPIC_PROGRESS, + TOPIC_STATUS, + OrderInput, + ProgressEvent, + StatusEvent, + race_with_workflow, +) +from workflow_stream.workflows.order_workflow import OrderWorkflow + + +async def main() -> None: + client = await Client.connect("localhost:7233") + + workflow_id = f"workflow-stream-order-{uuid.uuid4().hex[:8]}" + handle = await client.start_workflow( + OrderWorkflow.run, + OrderInput(order_id="order-1"), + id=workflow_id, + task_queue=TASK_QUEUE, + ) + + stream = WorkflowStreamClient.create(client, workflow_id) + converter = client.data_converter.payload_converter + + async def consume() -> None: + # Single iterator over both topics — avoids a cancellation race + # between two concurrent subscribers. result_type is left unset + # so we can dispatch heterogeneous events on item.topic. + async for item in stream.subscribe([TOPIC_STATUS, TOPIC_PROGRESS]): + assert isinstance(item.data, Payload) + if item.topic == TOPIC_STATUS: + evt = converter.from_payload(item.data, StatusEvent) + print(f"[status] {evt.kind}: order={evt.order_id}") + if evt.kind == "complete": + return + elif item.topic == TOPIC_PROGRESS: + progress = converter.from_payload(item.data, ProgressEvent) + print(f"[progress] {progress.message}") + + result = await race_with_workflow(consume(), handle) + print(f"workflow result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_stream/run_worker.py b/workflow_stream/run_worker.py new file mode 100644 index 00000000..8fef1ab9 --- /dev/null +++ b/workflow_stream/run_worker.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import asyncio +import logging + +from temporalio.client import Client +from temporalio.worker import Worker + +from workflow_stream.activities.payment_activity import charge_card +from workflow_stream.shared import TASK_QUEUE +from workflow_stream.workflows.order_workflow import OrderWorkflow + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + client = await Client.connect("localhost:7233") + worker = Worker( + client, + task_queue=TASK_QUEUE, + workflows=[OrderWorkflow], + activities=[charge_card], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_stream/shared.py b/workflow_stream/shared.py new file mode 100644 index 00000000..ca1368c1 --- /dev/null +++ b/workflow_stream/shared.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +from dataclasses import dataclass +from typing import Any, TypeVar + +from temporalio.client import WorkflowHandle +from temporalio.contrib.workflow_stream import WorkflowStreamState + +TASK_QUEUE = "workflow-stream-sample-task-queue" + +# Topics published by the workflow / activity. +TOPIC_STATUS = "status" +TOPIC_PROGRESS = "progress" + + +@dataclass +class OrderInput: + order_id: str + # Carries stream state across continue-as-new. None on a fresh start. + stream_state: WorkflowStreamState | None = None + + +@dataclass +class StatusEvent: + kind: str + order_id: str + + +@dataclass +class ProgressEvent: + message: str + + +T = TypeVar("T") + + +async def race_with_workflow( + consumer: Coroutine[Any, Any, None], + handle: WorkflowHandle[Any, T], +) -> T: + """Run a subscriber concurrently with the workflow. + + If the workflow finishes before the subscriber sees its terminal + event, cancel the subscriber and surface the workflow's result + (raising on failure). If the subscriber finishes first, wait for + the workflow result. A non-cancellation failure in the subscriber + is propagated either way. + + Without this, a workflow that raises before publishing its terminal + event would leave the subscriber blocked on its next poll forever. + """ + consumer_task = asyncio.create_task(consumer) + result_task = asyncio.create_task(handle.result()) + we_cancelled_consumer = False + try: + await asyncio.wait( + [consumer_task, result_task], + return_when=asyncio.FIRST_COMPLETED, + ) + if not consumer_task.done(): + consumer_task.cancel() + we_cancelled_consumer = True + # gather(return_exceptions=True) drains both tasks. Only + # cancellation we initiated is expected — anything else + # propagates. + consumer_outcome, workflow_outcome = await asyncio.gather( + consumer_task, result_task, return_exceptions=True + ) + if isinstance(consumer_outcome, asyncio.CancelledError): + if not we_cancelled_consumer: + raise consumer_outcome + elif isinstance(consumer_outcome, BaseException): + raise consumer_outcome + if isinstance(workflow_outcome, BaseException): + raise workflow_outcome + return workflow_outcome + finally: + for task in (consumer_task, result_task): + if not task.done(): + task.cancel() + for task in (consumer_task, result_task): + try: + await task + except BaseException: + pass diff --git a/workflow_stream/workflows/__init__.py b/workflow_stream/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workflow_stream/workflows/order_workflow.py b/workflow_stream/workflows/order_workflow.py new file mode 100644 index 00000000..4b4f4a82 --- /dev/null +++ b/workflow_stream/workflows/order_workflow.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.workflow_stream import WorkflowStream + +from workflow_stream.shared import ( + TOPIC_PROGRESS, + TOPIC_STATUS, + OrderInput, + ProgressEvent, + StatusEvent, +) + +with workflow.unsafe.imports_passed_through(): + from workflow_stream.activities.payment_activity import charge_card + + +@workflow.defn +class OrderWorkflow: + """Process a fake order, publishing status and progress events. + + The workflow itself publishes status changes; an activity it runs + publishes finer-grained progress events using a + `WorkflowStreamClient`. A single stream carries both topics — + subscribers can filter on the topic(s) they care about. + """ + + @workflow.init + def __init__(self, input: OrderInput) -> None: + # Construct the stream from @workflow.init so it can register + # signal/update/query handlers before the workflow accepts any + # messages. Threading prior_state lets the workflow survive + # continue-as-new without losing buffered items. + self.stream = WorkflowStream(prior_state=input.stream_state) + + @workflow.run + async def run(self, input: OrderInput) -> str: + self.stream.publish( + TOPIC_STATUS, StatusEvent(kind="received", order_id=input.order_id) + ) + + charge_id = await workflow.execute_activity( + charge_card, + input.order_id, + start_to_close_timeout=timedelta(seconds=30), + ) + + self.stream.publish( + TOPIC_STATUS, StatusEvent(kind="shipped", order_id=input.order_id) + ) + self.stream.publish( + TOPIC_PROGRESS, + ProgressEvent(message=f"charge id: {charge_id}"), + ) + self.stream.publish( + TOPIC_STATUS, StatusEvent(kind="complete", order_id=input.order_id) + ) + return charge_id From faac49f9585a468330409e16e92c88886a966ba2 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 10:07:33 -0700 Subject: [PATCH 02/23] samples: workflow_stream: add reconnecting-subscriber scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second scenario demonstrating the central Workflow Streams use case: a consumer disconnects mid-stream and resumes later via subscribe(from_offset=...), with no events lost or duplicated. The existing OrderWorkflow finishes too quickly to make the pattern visible, so this introduces a multi-stage PipelineWorkflow paced with workflow.sleep between stages. The runner reads a couple of events, persists item.offset + 1 to a temp file, sleeps "disconnected" while the workflow keeps publishing, then opens a fresh Client + WorkflowStreamClient and resumes from the persisted offset — the same shape that works across actual process restarts. Co-Authored-By: Claude Opus 4.7 (1M context) --- workflow_stream/README.md | 48 ++++++-- .../run_reconnecting_subscriber.py | 107 ++++++++++++++++++ workflow_stream/run_worker.py | 3 +- workflow_stream/shared.py | 12 ++ .../workflows/pipeline_workflow.py | 43 +++++++ 5 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 workflow_stream/run_reconnecting_subscriber.py create mode 100644 workflow_stream/workflows/pipeline_workflow.py diff --git a/workflow_stream/README.md b/workflow_stream/README.md index 3a57a8d7..dd4fcf49 100644 --- a/workflow_stream/README.md +++ b/workflow_stream/README.md @@ -16,16 +16,31 @@ signals and subscribe via long-poll updates. This packages the boilerplate — batching, offset tracking, topic filtering, continue-as-new hand-off — into a reusable stream. -This directory has a minimal end-to-end example: +This directory has two scenarios sharing one Worker. + +**Scenario 1 — basic publish/subscribe with heterogeneous topics:** * `workflows/order_workflow.py` — a workflow that hosts a `WorkflowStream` and publishes status events as it processes an order. * `activities/payment_activity.py` — an activity that publishes intermediate progress to the stream via `WorkflowStreamClient.from_activity()`. -* `run_worker.py` — registers the workflow and activity. -* `run_publisher.py` — starts the workflow, then prints subscribed - events as they arrive. +* `run_publisher.py` — starts the workflow, subscribes to both topics, + decodes each by `item.topic`, and prints events as they arrive. + +**Scenario 2 — reconnecting subscriber:** + +* `workflows/pipeline_workflow.py` — a multi-stage pipeline that + publishes stage transitions over ~10 seconds, leaving room for a + consumer to disconnect and reconnect mid-run. +* `run_reconnecting_subscriber.py` — connects, reads a couple of + events, persists `item.offset + 1` to disk, "disconnects," then + reopens a fresh client and resumes via `subscribe(from_offset=...)`. + This is the central Workflow Streams use case: a consumer can + disappear (page refresh, server restart, laptop closed) and resume + later without missing events or seeing duplicates. + +`run_worker.py` registers both workflows and the activity. ## Run it @@ -33,12 +48,13 @@ This directory has a minimal end-to-end example: # Terminal 1: worker uv run workflow_stream/run_worker.py -# Terminal 2: starter + subscriber +# Terminal 2: pick a scenario uv run workflow_stream/run_publisher.py +# or +uv run workflow_stream/run_reconnecting_subscriber.py ``` -Expected output on the publisher side, with events streaming in as the -workflow progresses: +Expected output on the basic publisher side: ``` [status] received: order=order-1 @@ -49,3 +65,21 @@ workflow progresses: [status] complete: order=order-1 workflow result: charge-order-1 ``` + +Expected output on the reconnecting subscriber side (note the offsets +are continuous across the disconnect — no events lost, none duplicated): + +``` +[phase 1] connecting and reading first few events + offset= 0 stage=validating + offset= 1 stage=loading data +[phase 1] persisted resume offset=2 -> /tmp/...; disconnecting + +[phase 2] reconnecting and resuming from persisted offset + offset= 2 stage=transforming + offset= 3 stage=writing output + offset= 4 stage=verifying + offset= 5 stage=complete + +workflow result: pipeline workflow-stream-pipeline-... done +``` diff --git a/workflow_stream/run_reconnecting_subscriber.py b/workflow_stream/run_reconnecting_subscriber.py new file mode 100644 index 00000000..3a5eee11 --- /dev/null +++ b/workflow_stream/run_reconnecting_subscriber.py @@ -0,0 +1,107 @@ +"""Reconnecting subscriber: persist offset, disconnect, resume. + +Demonstrates the central Workflow Streams use case: a consumer can +disappear mid-stream — page refresh, server restart, laptop closed — +and resume later without missing events or seeing duplicates. The +event log lives in the Workflow, so the consumer just remembers where +it stopped. + +The script runs the pattern in two phases inside one process to keep +the demo short. The same code shape works across actual process +restarts because the resume offset is persisted to disk between phases. + +Run the worker first (``uv run workflow_stream/run_worker.py``), then:: + + uv run workflow_stream/run_reconnecting_subscriber.py +""" + +from __future__ import annotations + +import asyncio +import tempfile +import uuid +from pathlib import Path + +from temporalio.client import Client +from temporalio.contrib.workflow_stream import WorkflowStreamClient + +from workflow_stream.shared import ( + TASK_QUEUE, + TOPIC_STATUS, + PipelineInput, + StageEvent, +) +from workflow_stream.workflows.pipeline_workflow import PipelineWorkflow + +# Number of events read in phase 1 before simulating a disconnect. +# Picked small enough that the workflow is still running after. +PHASE_1_EVENTS = 2 + + +async def main() -> None: + client = await Client.connect("localhost:7233") + + workflow_id = f"workflow-stream-pipeline-{uuid.uuid4().hex[:8]}" + handle = await client.start_workflow( + PipelineWorkflow.run, + PipelineInput(pipeline_id=workflow_id), + id=workflow_id, + task_queue=TASK_QUEUE, + ) + + # Where the consumer remembers its position. In a real BFF or UI + # backend this would be a database row keyed by (user_id, run_id); + # a temp file keeps the sample self-contained. + offset_path = Path(tempfile.gettempdir()) / f"{workflow_id}.offset" + + # ---- Phase 1: connect, read a couple of events, persist offset, disconnect. + print("[phase 1] connecting and reading first few events") + stream = WorkflowStreamClient.create(client, workflow_id) + seen = 0 + next_offset = 0 + async for item in stream.subscribe([TOPIC_STATUS], result_type=StageEvent): + print(f" offset={item.offset:2d} stage={item.data.stage}") + # Persist *one past* the offset just consumed. On resume we want + # the *next* unseen event, not the one we already showed. + next_offset = item.offset + 1 + offset_path.write_text(str(next_offset)) + seen += 1 + if seen >= PHASE_1_EVENTS: + break + + print( + f"[phase 1] persisted resume offset={next_offset} -> {offset_path}; disconnecting\n" + ) + # The async for loop exits the subscribe() iterator. Any background + # poll Update is cancelled. The workflow keeps running in the + # background, accumulating events into its log. + await asyncio.sleep(3) # let the workflow publish more in our absence + + # ---- Phase 2: reconnect, read persisted offset, resume from there. + print("[phase 2] reconnecting and resuming from persisted offset") + resume_from = int(offset_path.read_text()) + # A brand-new client and stream object — same shape as a different + # process picking up where the first one left off. + client2 = await Client.connect("localhost:7233") + stream2 = WorkflowStreamClient.create(client2, workflow_id) + async for item in stream2.subscribe( + [TOPIC_STATUS], + from_offset=resume_from, + result_type=StageEvent, + ): + print(f" offset={item.offset:2d} stage={item.data.stage}") + # Continue persisting after each event so a second crash here + # would also resume cleanly. + offset_path.write_text(str(item.offset + 1)) + if item.data.stage == "complete": + break + + result = await handle.result() + print(f"\nworkflow result: {result}") + # Clean up the offset file; in a real consumer you'd retain it as + # long as the user might reconnect. + offset_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_stream/run_worker.py b/workflow_stream/run_worker.py index 8fef1ab9..4b9a4ed5 100644 --- a/workflow_stream/run_worker.py +++ b/workflow_stream/run_worker.py @@ -9,6 +9,7 @@ from workflow_stream.activities.payment_activity import charge_card from workflow_stream.shared import TASK_QUEUE from workflow_stream.workflows.order_workflow import OrderWorkflow +from workflow_stream.workflows.pipeline_workflow import PipelineWorkflow async def main() -> None: @@ -17,7 +18,7 @@ async def main() -> None: worker = Worker( client, task_queue=TASK_QUEUE, - workflows=[OrderWorkflow], + workflows=[OrderWorkflow, PipelineWorkflow], activities=[charge_card], ) await worker.run() diff --git a/workflow_stream/shared.py b/workflow_stream/shared.py index ca1368c1..652c8fa5 100644 --- a/workflow_stream/shared.py +++ b/workflow_stream/shared.py @@ -33,6 +33,18 @@ class ProgressEvent: message: str +@dataclass +class PipelineInput: + pipeline_id: str + # Carries stream state across continue-as-new. None on a fresh start. + stream_state: WorkflowStreamState | None = None + + +@dataclass +class StageEvent: + stage: str + + T = TypeVar("T") diff --git a/workflow_stream/workflows/pipeline_workflow.py b/workflow_stream/workflows/pipeline_workflow.py new file mode 100644 index 00000000..5f53c1bf --- /dev/null +++ b/workflow_stream/workflows/pipeline_workflow.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.workflow_stream import WorkflowStream + +from workflow_stream.shared import ( + TOPIC_STATUS, + PipelineInput, + StageEvent, +) + + +@workflow.defn +class PipelineWorkflow: + """Multi-stage pipeline that publishes stage transitions over time. + + Stages are spaced out with ``workflow.sleep`` so a subscriber can + realistically disconnect partway through and reconnect without the + pipeline finishing in the meantime — the shape needed to demo the + "show up late and still see what happened" pattern. + """ + + @workflow.init + def __init__(self, input: PipelineInput) -> None: + self.stream = WorkflowStream(prior_state=input.stream_state) + + @workflow.run + async def run(self, input: PipelineInput) -> str: + stages = [ + "validating", + "loading data", + "transforming", + "writing output", + "verifying", + "complete", + ] + for stage in stages: + self.stream.publish(TOPIC_STATUS, StageEvent(stage=stage)) + if stage != "complete": + await workflow.sleep(timedelta(seconds=2)) + return f"pipeline {input.pipeline_id} done" From b607117f788fac2a0f1224d8f800f92a5a47ab29 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 10:10:10 -0700 Subject: [PATCH 03/23] samples: workflow_stream: add external-publisher scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third scenario covering the third publisher shape: a backend service or scheduled job pushing events into a workflow it didn't itself start. The earlier scenarios publish either from inside the workflow or from one of its activities; this one uses WorkflowStreamClient.create() externally. HubWorkflow is a passive stream host — it does no work of its own and just waits to be told to close, fitting the event-bus pattern. The runner publishes a series of news headlines, runs a subscriber task alongside, signals close, and exits when both tasks complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- workflow_stream/README.md | 18 ++++- workflow_stream/run_external_publisher.py | 91 +++++++++++++++++++++++ workflow_stream/run_worker.py | 3 +- workflow_stream/shared.py | 13 ++++ workflow_stream/workflows/hub_workflow.py | 36 +++++++++ 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 workflow_stream/run_external_publisher.py create mode 100644 workflow_stream/workflows/hub_workflow.py diff --git a/workflow_stream/README.md b/workflow_stream/README.md index dd4fcf49..7b43ce08 100644 --- a/workflow_stream/README.md +++ b/workflow_stream/README.md @@ -40,7 +40,21 @@ This directory has two scenarios sharing one Worker. disappear (page refresh, server restart, laptop closed) and resume later without missing events or seeing duplicates. -`run_worker.py` registers both workflows and the activity. +**Scenario 3 — external (non-Activity) publisher:** + +* `workflows/hub_workflow.py` — a passive workflow that does no work + of its own; it exists only to host a `WorkflowStream` and shut down + when signaled. +* `run_external_publisher.py` — starts the hub, then publishes events + into it from a plain Python coroutine using + `WorkflowStreamClient.create(client, workflow_id)`. A subscriber + task runs alongside; when the publisher is done it signals + `HubWorkflow.close`, the workflow's run finishes, and the + subscriber's iterator exits normally. This is the shape that fits a + backend service or scheduled job pushing events into a workflow it + didn't itself start. + +`run_worker.py` registers all three workflows and the activity. ## Run it @@ -52,6 +66,8 @@ uv run workflow_stream/run_worker.py uv run workflow_stream/run_publisher.py # or uv run workflow_stream/run_reconnecting_subscriber.py +# or +uv run workflow_stream/run_external_publisher.py ``` Expected output on the basic publisher side: diff --git a/workflow_stream/run_external_publisher.py b/workflow_stream/run_external_publisher.py new file mode 100644 index 00000000..5ef7e27e --- /dev/null +++ b/workflow_stream/run_external_publisher.py @@ -0,0 +1,91 @@ +"""External publisher: a non-Activity process pushes events into a workflow. + +The two earlier scenarios publish from inside the workflow itself +(``OrderWorkflow``, ``PipelineWorkflow``) or from an Activity it runs +(``charge_card``). This scenario shows the third shape: a backend +service, scheduled job, or anything else with a Temporal ``Client`` +publishing into a *running* workflow it didn't start. Same factory as +the subscribe path — :py:meth:`WorkflowStreamClient.create` — used for +publishing instead. + +The script starts a ``HubWorkflow`` (which does no work of its own — +it exists only to host the stream), then runs a publisher and a +subscriber concurrently. When the publisher is done it signals +``HubWorkflow.close``, the workflow's run finishes, and the +subscriber's iterator exits normally. + +Run the worker first (``uv run workflow_stream/run_worker.py``), then:: + + uv run workflow_stream/run_external_publisher.py +""" + +from __future__ import annotations + +import asyncio +import uuid + +from temporalio.client import Client +from temporalio.contrib.workflow_stream import WorkflowStreamClient + +from workflow_stream.shared import ( + TASK_QUEUE, + TOPIC_NEWS, + HubInput, + NewsEvent, +) +from workflow_stream.workflows.hub_workflow import HubWorkflow + + +HEADLINES = [ + "rates held", + "merger announced", + "outage resolved", + "earnings beat", + "regulator opens probe", +] + + +async def main() -> None: + client = await Client.connect("localhost:7233") + + workflow_id = f"workflow-stream-hub-{uuid.uuid4().hex[:8]}" + handle = await client.start_workflow( + HubWorkflow.run, + HubInput(hub_id=workflow_id), + id=workflow_id, + task_queue=TASK_QUEUE, + ) + + async def publish_news() -> None: + # WorkflowStreamClient.create takes a Temporal client and a + # workflow id — the same factory used elsewhere for subscribing. + # The async context manager batches publishes and flushes on + # exit; we additionally call flush() before signaling close so + # we know the events landed before the workflow shuts down. + producer = WorkflowStreamClient.create(client, workflow_id) + async with producer: + for headline in HEADLINES: + producer.publish(TOPIC_NEWS, NewsEvent(headline=headline)) + print(f"[publisher] sent: {headline}") + await asyncio.sleep(0.5) + await producer.flush() + # Tell the hub it can stop. The workflow's run() returns, and + # any in-flight subscribers see their async-for loop exit. + await handle.signal(HubWorkflow.close) + print("[publisher] signaled close") + + async def consume_news() -> None: + consumer = WorkflowStreamClient.create(client, workflow_id) + async for item in consumer.subscribe( + [TOPIC_NEWS], result_type=NewsEvent + ): + print(f"[subscriber] offset={item.offset}: {item.data.headline}") + + await asyncio.gather(publish_news(), consume_news()) + + result = await handle.result() + print(f"\nworkflow result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_stream/run_worker.py b/workflow_stream/run_worker.py index 4b9a4ed5..b118c22f 100644 --- a/workflow_stream/run_worker.py +++ b/workflow_stream/run_worker.py @@ -8,6 +8,7 @@ from workflow_stream.activities.payment_activity import charge_card from workflow_stream.shared import TASK_QUEUE +from workflow_stream.workflows.hub_workflow import HubWorkflow from workflow_stream.workflows.order_workflow import OrderWorkflow from workflow_stream.workflows.pipeline_workflow import PipelineWorkflow @@ -18,7 +19,7 @@ async def main() -> None: worker = Worker( client, task_queue=TASK_QUEUE, - workflows=[OrderWorkflow, PipelineWorkflow], + workflows=[HubWorkflow, OrderWorkflow, PipelineWorkflow], activities=[charge_card], ) await worker.run() diff --git a/workflow_stream/shared.py b/workflow_stream/shared.py index 652c8fa5..42e94015 100644 --- a/workflow_stream/shared.py +++ b/workflow_stream/shared.py @@ -13,6 +13,7 @@ # Topics published by the workflow / activity. TOPIC_STATUS = "status" TOPIC_PROGRESS = "progress" +TOPIC_NEWS = "news" @dataclass @@ -45,6 +46,18 @@ class StageEvent: stage: str +@dataclass +class HubInput: + hub_id: str + # Carries stream state across continue-as-new. None on a fresh start. + stream_state: WorkflowStreamState | None = None + + +@dataclass +class NewsEvent: + headline: str + + T = TypeVar("T") diff --git a/workflow_stream/workflows/hub_workflow.py b/workflow_stream/workflows/hub_workflow.py new file mode 100644 index 00000000..eb686963 --- /dev/null +++ b/workflow_stream/workflows/hub_workflow.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from temporalio import workflow +from temporalio.contrib.workflow_stream import WorkflowStream + +from workflow_stream.shared import HubInput + + +@workflow.defn +class HubWorkflow: + """Passive stream host: starts up, waits, closes when told. + + Unlike OrderWorkflow or PipelineWorkflow, this workflow does no + work of its own — it exists only to host a ``WorkflowStream`` that + external publishers push events into and external subscribers read + from. The shape that fits a backend service or "event bus" pattern, + where the workflow owns durable state but the events come from + outside. + """ + + @workflow.init + def __init__(self, input: HubInput) -> None: + self.stream = WorkflowStream(prior_state=input.stream_state) + self._closed = False + + @workflow.run + async def run(self, input: HubInput) -> str: + await workflow.wait_condition(lambda: self._closed) + return f"hub {input.hub_id} closed" + + @workflow.signal + def close(self) -> None: + # Custom signal handler that does not read stream state, so the + # synchronous-handler race documented in the README does not + # apply. + self._closed = True From 91233b0dbb4b84599b09b9c3f02aeab5d834d78e Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 10:12:56 -0700 Subject: [PATCH 04/23] samples: workflow_stream: add truncating-ticker scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth scenario for long-running workflows that need to bound their event log: the workflow publishes events at a fixed cadence and calls self.stream.truncate(...) periodically to keep only the most recent entries. The runner subscribes twice — fast and slow — to make the trade visible: the fast subscriber sees every offset in order; the slow one falls behind a truncation, has its iterator transparently jump forward to the new base offset, and shows the offset gap that intermediate events fell into. This is the model for high-volume long-running streams: bounded log size, slow consumers may miss intermediate events but always see the most recent state. Co-Authored-By: Claude Opus 4.7 (1M context) --- workflow_stream/README.md | 17 +++- workflow_stream/run_truncating_ticker.py | 83 ++++++++++++++++++++ workflow_stream/run_worker.py | 3 +- workflow_stream/shared.py | 16 ++++ workflow_stream/workflows/ticker_workflow.py | 59 ++++++++++++++ 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 workflow_stream/run_truncating_ticker.py create mode 100644 workflow_stream/workflows/ticker_workflow.py diff --git a/workflow_stream/README.md b/workflow_stream/README.md index 7b43ce08..a452e24b 100644 --- a/workflow_stream/README.md +++ b/workflow_stream/README.md @@ -54,7 +54,20 @@ This directory has two scenarios sharing one Worker. backend service or scheduled job pushing events into a workflow it didn't itself start. -`run_worker.py` registers all three workflows and the activity. +**Scenario 4 — bounded log via `truncate()`:** + +* `workflows/ticker_workflow.py` — a long-running workflow that + publishes events at a fixed cadence and calls + `self.stream.truncate(...)` periodically to bound log growth, keeping + only the most recent N entries. +* `run_truncating_ticker.py` — runs a fast subscriber and a slow + subscriber side by side. The fast one keeps up and sees every offset + in order; the slow one sleeps between iterations, falls behind a + truncation, and silently jumps forward to the new base offset. The + output makes the trade visible: bounded log size in exchange for + intermediate events being invisible to slow consumers. + +`run_worker.py` registers all four workflows and the activity. ## Run it @@ -68,6 +81,8 @@ uv run workflow_stream/run_publisher.py uv run workflow_stream/run_reconnecting_subscriber.py # or uv run workflow_stream/run_external_publisher.py +# or +uv run workflow_stream/run_truncating_ticker.py ``` Expected output on the basic publisher side: diff --git a/workflow_stream/run_truncating_ticker.py b/workflow_stream/run_truncating_ticker.py new file mode 100644 index 00000000..069ab4e4 --- /dev/null +++ b/workflow_stream/run_truncating_ticker.py @@ -0,0 +1,83 @@ +"""Truncating ticker: bounded log + slow vs. fast subscribers. + +The ``TickerWorkflow`` publishes ``count`` events at a fixed interval, +calling ``self.stream.truncate(...)`` periodically to bound log +growth. This script subscribes twice — once fast, once slow — and +prints both side-by-side so the trade is visible: + +* The fast subscriber keeps up and sees every published offset in + order. +* The slow subscriber sleeps between iterations. When a truncation + runs past its position, the iterator silently jumps forward to the + new base offset — the slow subscriber's offsets jump too, and + intermediate events are not visible to it. + +This is the bounded-log model: log size is capped, slow consumers may +miss intermediate events, but they always see the most recent state. +For long-running workflows pushing high event volumes this is usually +the right trade — pair with set-semantic events where each event +carries enough state to make missing the prior ones recoverable. + +Run the worker first (``uv run workflow_stream/run_worker.py``), then:: + + uv run workflow_stream/run_truncating_ticker.py +""" + +from __future__ import annotations + +import asyncio +import uuid + +from temporalio.client import Client +from temporalio.contrib.workflow_stream import WorkflowStreamClient + +from workflow_stream.shared import ( + TASK_QUEUE, + TOPIC_TICK, + TickerInput, + TickEvent, +) +from workflow_stream.workflows.ticker_workflow import TickerWorkflow + + +SLOW_SUBSCRIBER_DELAY_S = 1.5 + + +async def main() -> None: + client = await Client.connect("localhost:7233") + + workflow_id = f"workflow-stream-ticker-{uuid.uuid4().hex[:8]}" + handle = await client.start_workflow( + TickerWorkflow.run, + TickerInput( + count=20, + keep_last=3, + truncate_every=5, + interval_ms=400, + ), + id=workflow_id, + task_queue=TASK_QUEUE, + ) + + stream = WorkflowStreamClient.create(client, workflow_id) + + async def fast_subscriber() -> None: + async for item in stream.subscribe([TOPIC_TICK], result_type=TickEvent): + print(f"[fast] offset={item.offset:3d} n={item.data.n}") + + async def slow_subscriber() -> None: + async for item in stream.subscribe([TOPIC_TICK], result_type=TickEvent): + print(f"[SLOW] offset={item.offset:3d} n={item.data.n}") + await asyncio.sleep(SLOW_SUBSCRIBER_DELAY_S) + + # Both iterators exit normally when the workflow completes. No + # terminal sentinel is needed — see the doc's "When the Workflow + # run completes" note. + await asyncio.gather(fast_subscriber(), slow_subscriber()) + + result = await handle.result() + print(f"\nworkflow result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_stream/run_worker.py b/workflow_stream/run_worker.py index b118c22f..9e21982e 100644 --- a/workflow_stream/run_worker.py +++ b/workflow_stream/run_worker.py @@ -11,6 +11,7 @@ from workflow_stream.workflows.hub_workflow import HubWorkflow from workflow_stream.workflows.order_workflow import OrderWorkflow from workflow_stream.workflows.pipeline_workflow import PipelineWorkflow +from workflow_stream.workflows.ticker_workflow import TickerWorkflow async def main() -> None: @@ -19,7 +20,7 @@ async def main() -> None: worker = Worker( client, task_queue=TASK_QUEUE, - workflows=[HubWorkflow, OrderWorkflow, PipelineWorkflow], + workflows=[HubWorkflow, OrderWorkflow, PipelineWorkflow, TickerWorkflow], activities=[charge_card], ) await worker.run() diff --git a/workflow_stream/shared.py b/workflow_stream/shared.py index 42e94015..fd97a6a8 100644 --- a/workflow_stream/shared.py +++ b/workflow_stream/shared.py @@ -14,6 +14,7 @@ TOPIC_STATUS = "status" TOPIC_PROGRESS = "progress" TOPIC_NEWS = "news" +TOPIC_TICK = "tick" @dataclass @@ -58,6 +59,21 @@ class NewsEvent: headline: str +@dataclass +class TickerInput: + count: int = 20 + keep_last: int = 3 + truncate_every: int = 5 + interval_ms: int = 400 + # Carries stream state across continue-as-new. None on a fresh start. + stream_state: WorkflowStreamState | None = None + + +@dataclass +class TickEvent: + n: int + + T = TypeVar("T") diff --git a/workflow_stream/workflows/ticker_workflow.py b/workflow_stream/workflows/ticker_workflow.py new file mode 100644 index 00000000..61f895a2 --- /dev/null +++ b/workflow_stream/workflows/ticker_workflow.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.workflow_stream import WorkflowStream + +from workflow_stream.shared import ( + TOPIC_TICK, + TickEvent, + TickerInput, +) + + +@workflow.defn +class TickerWorkflow: + """Long-running ticker that bounds its event log via ``truncate``. + + Long-running workflows that publish high volumes of events would + otherwise grow their event log unboundedly. This workflow shows + the truncation pattern: every ``truncate_every`` events, drop + everything except the last ``keep_last`` entries by calling + ``self.stream.truncate(safe_offset)``. + + Subscribers that fall behind a truncation jump forward to the new + base offset transparently (the iterator handles the + ``TruncatedOffset`` error internally), so consumers stay live but + may not see every intermediate event. That is the trade: bounded + log size in exchange for at-best-effort delivery to slow + consumers. + + To compute the truncation offset the workflow tracks its own + published count. ``WorkflowStream`` does not expose a workflow-side + head-offset accessor, but the running count plus the carried + ``base_offset`` (in continue-as-new chains) is sufficient. + """ + + @workflow.init + def __init__(self, input: TickerInput) -> None: + self.stream = WorkflowStream(prior_state=input.stream_state) + # Running count of events published by THIS run. To compute a + # global offset, add the prior_state's base_offset (omitted + # here — this sample doesn't continue-as-new). + self._published = 0 + + @workflow.run + async def run(self, input: TickerInput) -> str: + for n in range(input.count): + self.stream.publish(TOPIC_TICK, TickEvent(n=n)) + self._published += 1 + await workflow.sleep(timedelta(milliseconds=input.interval_ms)) + if ( + self._published % input.truncate_every == 0 + and self._published > input.keep_last + ): + # Drop everything except the last `keep_last` entries. + truncate_to = self._published - input.keep_last + self.stream.truncate(truncate_to) + return f"ticker emitted {self._published} events" From 78062b4dbdef2988a97fec9a057f19b28915319b Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 17:29:13 -0700 Subject: [PATCH 05/23] =?UTF-8?q?samples:=20rename=20workflow=5Fstream=20?= =?UTF-8?q?=E2=86=92=20workflow=5Fstreams;=20migrate=20to=20topic=20handle?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Directory and module path renamed to plural to match sdk-python `temporalio.contrib.workflow_streams` rename. - Workflow-side: bind a typed topic handle in `@workflow.init` and call `topic.publish(value)` — the removed `WorkflowStream.publish` form is gone. Same change applied to the activity and external-publisher. - Activity: `WorkflowStreamClient.from_activity()` → `from_within_activity()`. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- .../activities/payment_activity.py | 41 ------------------- .../README.md | 16 ++++---- .../__init__.py | 0 .../activities/__init__.py | 0 .../activities/payment_activity.py | 41 +++++++++++++++++++ .../run_external_publisher.py | 13 +++--- .../run_publisher.py | 6 +-- .../run_reconnecting_subscriber.py | 10 ++--- .../run_truncating_ticker.py | 10 ++--- .../run_worker.py | 12 +++--- .../shared.py | 2 +- .../workflows/__init__.py | 0 .../workflows/hub_workflow.py | 4 +- .../workflows/order_workflow.py | 25 ++++------- .../workflows/pipeline_workflow.py | 7 ++-- .../workflows/ticker_workflow.py | 7 ++-- 17 files changed, 96 insertions(+), 100 deletions(-) delete mode 100644 workflow_stream/activities/payment_activity.py rename {workflow_stream => workflow_streams}/README.md (91%) rename {workflow_stream => workflow_streams}/__init__.py (100%) rename {workflow_stream => workflow_streams}/activities/__init__.py (100%) create mode 100644 workflow_streams/activities/payment_activity.py rename {workflow_stream => workflow_streams}/run_external_publisher.py (86%) rename {workflow_stream => workflow_streams}/run_publisher.py (90%) rename {workflow_stream => workflow_streams}/run_reconnecting_subscriber.py (92%) rename {workflow_stream => workflow_streams}/run_truncating_ticker.py (89%) rename {workflow_stream => workflow_streams}/run_worker.py (57%) rename {workflow_stream => workflow_streams}/shared.py (98%) rename {workflow_stream => workflow_streams}/workflows/__init__.py (100%) rename {workflow_stream => workflow_streams}/workflows/hub_workflow.py (91%) rename {workflow_stream => workflow_streams}/workflows/order_workflow.py (66%) rename {workflow_stream => workflow_streams}/workflows/pipeline_workflow.py (83%) rename {workflow_stream => workflow_streams}/workflows/ticker_workflow.py (91%) diff --git a/README.md b/README.md index 5a5c937f..80cda649 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Some examples require extra dependencies. See each sample's directory for specif * [patching](patching) - Alter workflows safely with `patch` and `deprecate_patch`. * [polling](polling) - Recommended implementation of an activity that needs to periodically poll an external resource waiting its successful completion. * [prometheus](prometheus) - Configure Prometheus metrics on clients/workers. -* [workflow_stream](workflow_stream) - Workflow-hosted durable event stream via `temporalio.contrib.workflow_stream`. **Experimental — requires the [`contrib/pubsub` branch](https://github.com/temporalio/sdk-python/tree/contrib/pubsub) of sdk-python.** +* [workflow_streams](workflow_streams) - Workflow-hosted durable event stream via `temporalio.contrib.workflow_streams`. **Experimental — requires the [`contrib/pubsub` branch](https://github.com/temporalio/sdk-python/tree/contrib/pubsub) of sdk-python.** * [pydantic_converter](pydantic_converter) - Data converter for using Pydantic models. * [schedules](schedules) - Demonstrates a Workflow Execution that occurs according to a schedule. * [sentry](sentry) - Report errors to Sentry. diff --git a/workflow_stream/activities/payment_activity.py b/workflow_stream/activities/payment_activity.py deleted file mode 100644 index f69f8c8d..00000000 --- a/workflow_stream/activities/payment_activity.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -import asyncio -from datetime import timedelta - -from temporalio import activity -from temporalio.contrib.workflow_stream import WorkflowStreamClient - -from workflow_stream.shared import TOPIC_PROGRESS, ProgressEvent - - -@activity.defn -async def charge_card(order_id: str) -> str: - """Pretend to charge a card, publishing progress to the parent workflow. - - `WorkflowStreamClient.from_activity()` reads the parent workflow id - and the Temporal client from the activity context, so this activity - can push events back without any wiring. - - Caveat: each call to ``from_activity()`` creates a fresh client with - a random ``publisher_id``, so dedup does not protect against an - activity retry republishing the same events. For activities that - must be exactly-once on the stream side, derive a stable - ``publisher_id`` from ``activity.info().activity_id`` (this is - invariant across attempts of the same scheduled activity). The - current ``WorkflowStreamClient`` API does not yet expose - ``publisher_id`` on its constructors; this sample accepts - at-most-once-per-attempt semantics. - """ - client = WorkflowStreamClient.from_activity( - batch_interval=timedelta(milliseconds=200) - ) - async with client: - client.publish(TOPIC_PROGRESS, ProgressEvent(message="charging card...")) - await asyncio.sleep(1.0) - client.publish( - TOPIC_PROGRESS, - ProgressEvent(message="card charged"), - force_flush=True, - ) - return f"charge-{order_id}" diff --git a/workflow_stream/README.md b/workflow_streams/README.md similarity index 91% rename from workflow_stream/README.md rename to workflow_streams/README.md index a452e24b..1d6f167c 100644 --- a/workflow_stream/README.md +++ b/workflow_streams/README.md @@ -1,7 +1,7 @@ # Workflow Streams > **Experimental.** These samples target the -> `temporalio.contrib.workflow_stream` module on the +> `temporalio.contrib.workflow_streams` module on the > [`contrib/pubsub` branch of sdk-python][branch], which is not yet > released. To run them locally, install sdk-python from that branch > (e.g. `uv pip install -e ` after checking out the @@ -9,7 +9,7 @@ [branch]: https://github.com/temporalio/sdk-python/tree/contrib/pubsub -`temporalio.contrib.workflow_stream` lets a workflow host a durable, +`temporalio.contrib.workflow_streams` lets a workflow host a durable, offset-addressed event channel. The workflow holds an append-only log; external clients (activities, starters, BFFs) publish to topics via signals and subscribe via long-poll updates. This packages the @@ -24,7 +24,7 @@ This directory has two scenarios sharing one Worker. `WorkflowStream` and publishes status events as it processes an order. * `activities/payment_activity.py` — an activity that publishes intermediate progress to the stream via - `WorkflowStreamClient.from_activity()`. + `WorkflowStreamClient.from_within_activity()`. * `run_publisher.py` — starts the workflow, subscribes to both topics, decodes each by `item.topic`, and prints events as they arrive. @@ -73,16 +73,16 @@ This directory has two scenarios sharing one Worker. ```bash # Terminal 1: worker -uv run workflow_stream/run_worker.py +uv run workflow_streams/run_worker.py # Terminal 2: pick a scenario -uv run workflow_stream/run_publisher.py +uv run workflow_streams/run_publisher.py # or -uv run workflow_stream/run_reconnecting_subscriber.py +uv run workflow_streams/run_reconnecting_subscriber.py # or -uv run workflow_stream/run_external_publisher.py +uv run workflow_streams/run_external_publisher.py # or -uv run workflow_stream/run_truncating_ticker.py +uv run workflow_streams/run_truncating_ticker.py ``` Expected output on the basic publisher side: diff --git a/workflow_stream/__init__.py b/workflow_streams/__init__.py similarity index 100% rename from workflow_stream/__init__.py rename to workflow_streams/__init__.py diff --git a/workflow_stream/activities/__init__.py b/workflow_streams/activities/__init__.py similarity index 100% rename from workflow_stream/activities/__init__.py rename to workflow_streams/activities/__init__.py diff --git a/workflow_streams/activities/payment_activity.py b/workflow_streams/activities/payment_activity.py new file mode 100644 index 00000000..d94a071b --- /dev/null +++ b/workflow_streams/activities/payment_activity.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from temporalio import activity +from temporalio.contrib.workflow_streams import WorkflowStreamClient + +from workflow_streams.shared import TOPIC_PROGRESS, ProgressEvent + + +@activity.defn +async def charge_card(order_id: str) -> str: + """Pretend to charge a card, publishing progress to the parent workflow. + + `WorkflowStreamClient.from_within_activity()` reads the parent + workflow id and the Temporal client from the activity context, so + this activity can push events back without any wiring. + + Caveat: each call to ``from_within_activity()`` creates a fresh + client with a random ``publisher_id``, so dedup does not protect + against an activity retry republishing the same events. For + activities that must be exactly-once on the stream side, derive a + stable ``publisher_id`` from ``activity.info().activity_id`` (this + is invariant across attempts of the same scheduled activity). The + current ``WorkflowStreamClient`` API does not yet expose + ``publisher_id`` on its constructors; this sample accepts + at-most-once-per-attempt semantics. + """ + client = WorkflowStreamClient.from_within_activity( + batch_interval=timedelta(milliseconds=200) + ) + async with client: + progress = client.topic(TOPIC_PROGRESS, type=ProgressEvent) + progress.publish(ProgressEvent(message="charging card...")) + await asyncio.sleep(1.0) + progress.publish( + ProgressEvent(message="card charged"), + force_flush=True, + ) + return f"charge-{order_id}" diff --git a/workflow_stream/run_external_publisher.py b/workflow_streams/run_external_publisher.py similarity index 86% rename from workflow_stream/run_external_publisher.py rename to workflow_streams/run_external_publisher.py index 5ef7e27e..1663ed31 100644 --- a/workflow_stream/run_external_publisher.py +++ b/workflow_streams/run_external_publisher.py @@ -14,9 +14,9 @@ ``HubWorkflow.close``, the workflow's run finishes, and the subscriber's iterator exits normally. -Run the worker first (``uv run workflow_stream/run_worker.py``), then:: +Run the worker first (``uv run workflow_streams/run_worker.py``), then:: - uv run workflow_stream/run_external_publisher.py + uv run workflow_streams/run_external_publisher.py """ from __future__ import annotations @@ -25,15 +25,15 @@ import uuid from temporalio.client import Client -from temporalio.contrib.workflow_stream import WorkflowStreamClient +from temporalio.contrib.workflow_streams import WorkflowStreamClient -from workflow_stream.shared import ( +from workflow_streams.shared import ( TASK_QUEUE, TOPIC_NEWS, HubInput, NewsEvent, ) -from workflow_stream.workflows.hub_workflow import HubWorkflow +from workflow_streams.workflows.hub_workflow import HubWorkflow HEADLINES = [ @@ -64,8 +64,9 @@ async def publish_news() -> None: # we know the events landed before the workflow shuts down. producer = WorkflowStreamClient.create(client, workflow_id) async with producer: + news = producer.topic(TOPIC_NEWS, type=NewsEvent) for headline in HEADLINES: - producer.publish(TOPIC_NEWS, NewsEvent(headline=headline)) + news.publish(NewsEvent(headline=headline)) print(f"[publisher] sent: {headline}") await asyncio.sleep(0.5) await producer.flush() diff --git a/workflow_stream/run_publisher.py b/workflow_streams/run_publisher.py similarity index 90% rename from workflow_stream/run_publisher.py rename to workflow_streams/run_publisher.py index cdfb210d..85c967ee 100644 --- a/workflow_stream/run_publisher.py +++ b/workflow_streams/run_publisher.py @@ -5,9 +5,9 @@ from temporalio.api.common.v1 import Payload from temporalio.client import Client -from temporalio.contrib.workflow_stream import WorkflowStreamClient +from temporalio.contrib.workflow_streams import WorkflowStreamClient -from workflow_stream.shared import ( +from workflow_streams.shared import ( TASK_QUEUE, TOPIC_PROGRESS, TOPIC_STATUS, @@ -16,7 +16,7 @@ StatusEvent, race_with_workflow, ) -from workflow_stream.workflows.order_workflow import OrderWorkflow +from workflow_streams.workflows.order_workflow import OrderWorkflow async def main() -> None: diff --git a/workflow_stream/run_reconnecting_subscriber.py b/workflow_streams/run_reconnecting_subscriber.py similarity index 92% rename from workflow_stream/run_reconnecting_subscriber.py rename to workflow_streams/run_reconnecting_subscriber.py index 3a5eee11..3aae76c6 100644 --- a/workflow_stream/run_reconnecting_subscriber.py +++ b/workflow_streams/run_reconnecting_subscriber.py @@ -10,9 +10,9 @@ the demo short. The same code shape works across actual process restarts because the resume offset is persisted to disk between phases. -Run the worker first (``uv run workflow_stream/run_worker.py``), then:: +Run the worker first (``uv run workflow_streams/run_worker.py``), then:: - uv run workflow_stream/run_reconnecting_subscriber.py + uv run workflow_streams/run_reconnecting_subscriber.py """ from __future__ import annotations @@ -23,15 +23,15 @@ from pathlib import Path from temporalio.client import Client -from temporalio.contrib.workflow_stream import WorkflowStreamClient +from temporalio.contrib.workflow_streams import WorkflowStreamClient -from workflow_stream.shared import ( +from workflow_streams.shared import ( TASK_QUEUE, TOPIC_STATUS, PipelineInput, StageEvent, ) -from workflow_stream.workflows.pipeline_workflow import PipelineWorkflow +from workflow_streams.workflows.pipeline_workflow import PipelineWorkflow # Number of events read in phase 1 before simulating a disconnect. # Picked small enough that the workflow is still running after. diff --git a/workflow_stream/run_truncating_ticker.py b/workflow_streams/run_truncating_ticker.py similarity index 89% rename from workflow_stream/run_truncating_ticker.py rename to workflow_streams/run_truncating_ticker.py index 069ab4e4..50876a0d 100644 --- a/workflow_stream/run_truncating_ticker.py +++ b/workflow_streams/run_truncating_ticker.py @@ -18,9 +18,9 @@ the right trade — pair with set-semantic events where each event carries enough state to make missing the prior ones recoverable. -Run the worker first (``uv run workflow_stream/run_worker.py``), then:: +Run the worker first (``uv run workflow_streams/run_worker.py``), then:: - uv run workflow_stream/run_truncating_ticker.py + uv run workflow_streams/run_truncating_ticker.py """ from __future__ import annotations @@ -29,15 +29,15 @@ import uuid from temporalio.client import Client -from temporalio.contrib.workflow_stream import WorkflowStreamClient +from temporalio.contrib.workflow_streams import WorkflowStreamClient -from workflow_stream.shared import ( +from workflow_streams.shared import ( TASK_QUEUE, TOPIC_TICK, TickerInput, TickEvent, ) -from workflow_stream.workflows.ticker_workflow import TickerWorkflow +from workflow_streams.workflows.ticker_workflow import TickerWorkflow SLOW_SUBSCRIBER_DELAY_S = 1.5 diff --git a/workflow_stream/run_worker.py b/workflow_streams/run_worker.py similarity index 57% rename from workflow_stream/run_worker.py rename to workflow_streams/run_worker.py index 9e21982e..8aa12edc 100644 --- a/workflow_stream/run_worker.py +++ b/workflow_streams/run_worker.py @@ -6,12 +6,12 @@ from temporalio.client import Client from temporalio.worker import Worker -from workflow_stream.activities.payment_activity import charge_card -from workflow_stream.shared import TASK_QUEUE -from workflow_stream.workflows.hub_workflow import HubWorkflow -from workflow_stream.workflows.order_workflow import OrderWorkflow -from workflow_stream.workflows.pipeline_workflow import PipelineWorkflow -from workflow_stream.workflows.ticker_workflow import TickerWorkflow +from workflow_streams.activities.payment_activity import charge_card +from workflow_streams.shared import TASK_QUEUE +from workflow_streams.workflows.hub_workflow import HubWorkflow +from workflow_streams.workflows.order_workflow import OrderWorkflow +from workflow_streams.workflows.pipeline_workflow import PipelineWorkflow +from workflow_streams.workflows.ticker_workflow import TickerWorkflow async def main() -> None: diff --git a/workflow_stream/shared.py b/workflow_streams/shared.py similarity index 98% rename from workflow_stream/shared.py rename to workflow_streams/shared.py index fd97a6a8..746ee73d 100644 --- a/workflow_stream/shared.py +++ b/workflow_streams/shared.py @@ -6,7 +6,7 @@ from typing import Any, TypeVar from temporalio.client import WorkflowHandle -from temporalio.contrib.workflow_stream import WorkflowStreamState +from temporalio.contrib.workflow_streams import WorkflowStreamState TASK_QUEUE = "workflow-stream-sample-task-queue" diff --git a/workflow_stream/workflows/__init__.py b/workflow_streams/workflows/__init__.py similarity index 100% rename from workflow_stream/workflows/__init__.py rename to workflow_streams/workflows/__init__.py diff --git a/workflow_stream/workflows/hub_workflow.py b/workflow_streams/workflows/hub_workflow.py similarity index 91% rename from workflow_stream/workflows/hub_workflow.py rename to workflow_streams/workflows/hub_workflow.py index eb686963..fdf7da56 100644 --- a/workflow_stream/workflows/hub_workflow.py +++ b/workflow_streams/workflows/hub_workflow.py @@ -1,9 +1,9 @@ from __future__ import annotations from temporalio import workflow -from temporalio.contrib.workflow_stream import WorkflowStream +from temporalio.contrib.workflow_streams import WorkflowStream -from workflow_stream.shared import HubInput +from workflow_streams.shared import HubInput @workflow.defn diff --git a/workflow_stream/workflows/order_workflow.py b/workflow_streams/workflows/order_workflow.py similarity index 66% rename from workflow_stream/workflows/order_workflow.py rename to workflow_streams/workflows/order_workflow.py index 4b4f4a82..8b944508 100644 --- a/workflow_stream/workflows/order_workflow.py +++ b/workflow_streams/workflows/order_workflow.py @@ -3,9 +3,9 @@ from datetime import timedelta from temporalio import workflow -from temporalio.contrib.workflow_stream import WorkflowStream +from temporalio.contrib.workflow_streams import WorkflowStream -from workflow_stream.shared import ( +from workflow_streams.shared import ( TOPIC_PROGRESS, TOPIC_STATUS, OrderInput, @@ -14,7 +14,7 @@ ) with workflow.unsafe.imports_passed_through(): - from workflow_stream.activities.payment_activity import charge_card + from workflow_streams.activities.payment_activity import charge_card @workflow.defn @@ -34,12 +34,12 @@ def __init__(self, input: OrderInput) -> None: # messages. Threading prior_state lets the workflow survive # continue-as-new without losing buffered items. self.stream = WorkflowStream(prior_state=input.stream_state) + self.status = self.stream.topic(TOPIC_STATUS, type=StatusEvent) + self.progress = self.stream.topic(TOPIC_PROGRESS, type=ProgressEvent) @workflow.run async def run(self, input: OrderInput) -> str: - self.stream.publish( - TOPIC_STATUS, StatusEvent(kind="received", order_id=input.order_id) - ) + self.status.publish(StatusEvent(kind="received", order_id=input.order_id)) charge_id = await workflow.execute_activity( charge_card, @@ -47,14 +47,7 @@ async def run(self, input: OrderInput) -> str: start_to_close_timeout=timedelta(seconds=30), ) - self.stream.publish( - TOPIC_STATUS, StatusEvent(kind="shipped", order_id=input.order_id) - ) - self.stream.publish( - TOPIC_PROGRESS, - ProgressEvent(message=f"charge id: {charge_id}"), - ) - self.stream.publish( - TOPIC_STATUS, StatusEvent(kind="complete", order_id=input.order_id) - ) + self.status.publish(StatusEvent(kind="shipped", order_id=input.order_id)) + self.progress.publish(ProgressEvent(message=f"charge id: {charge_id}")) + self.status.publish(StatusEvent(kind="complete", order_id=input.order_id)) return charge_id diff --git a/workflow_stream/workflows/pipeline_workflow.py b/workflow_streams/workflows/pipeline_workflow.py similarity index 83% rename from workflow_stream/workflows/pipeline_workflow.py rename to workflow_streams/workflows/pipeline_workflow.py index 5f53c1bf..a2d96d95 100644 --- a/workflow_stream/workflows/pipeline_workflow.py +++ b/workflow_streams/workflows/pipeline_workflow.py @@ -3,9 +3,9 @@ from datetime import timedelta from temporalio import workflow -from temporalio.contrib.workflow_stream import WorkflowStream +from temporalio.contrib.workflow_streams import WorkflowStream -from workflow_stream.shared import ( +from workflow_streams.shared import ( TOPIC_STATUS, PipelineInput, StageEvent, @@ -25,6 +25,7 @@ class PipelineWorkflow: @workflow.init def __init__(self, input: PipelineInput) -> None: self.stream = WorkflowStream(prior_state=input.stream_state) + self.status = self.stream.topic(TOPIC_STATUS, type=StageEvent) @workflow.run async def run(self, input: PipelineInput) -> str: @@ -37,7 +38,7 @@ async def run(self, input: PipelineInput) -> str: "complete", ] for stage in stages: - self.stream.publish(TOPIC_STATUS, StageEvent(stage=stage)) + self.status.publish(StageEvent(stage=stage)) if stage != "complete": await workflow.sleep(timedelta(seconds=2)) return f"pipeline {input.pipeline_id} done" diff --git a/workflow_stream/workflows/ticker_workflow.py b/workflow_streams/workflows/ticker_workflow.py similarity index 91% rename from workflow_stream/workflows/ticker_workflow.py rename to workflow_streams/workflows/ticker_workflow.py index 61f895a2..e11616b4 100644 --- a/workflow_stream/workflows/ticker_workflow.py +++ b/workflow_streams/workflows/ticker_workflow.py @@ -3,9 +3,9 @@ from datetime import timedelta from temporalio import workflow -from temporalio.contrib.workflow_stream import WorkflowStream +from temporalio.contrib.workflow_streams import WorkflowStream -from workflow_stream.shared import ( +from workflow_streams.shared import ( TOPIC_TICK, TickEvent, TickerInput, @@ -38,6 +38,7 @@ class TickerWorkflow: @workflow.init def __init__(self, input: TickerInput) -> None: self.stream = WorkflowStream(prior_state=input.stream_state) + self.tick = self.stream.topic(TOPIC_TICK, type=TickEvent) # Running count of events published by THIS run. To compute a # global offset, add the prior_state's base_offset (omitted # here — this sample doesn't continue-as-new). @@ -46,7 +47,7 @@ def __init__(self, input: TickerInput) -> None: @workflow.run async def run(self, input: TickerInput) -> str: for n in range(input.count): - self.stream.publish(TOPIC_TICK, TickEvent(n=n)) + self.tick.publish(TickEvent(n=n)) self._published += 1 await workflow.sleep(timedelta(milliseconds=input.interval_ms)) if ( From 5d67b9ec73e34ac34b2f44feb53ee4b243a01585 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Wed, 29 Apr 2026 20:49:45 -0700 Subject: [PATCH 06/23] samples: workflow_streams review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: fix scenario count (two -> four), document subscriber start position and continue-as-new semantics for stream_state - hub_workflow: drop stale comment referencing a README race note that does not exist in this sample - payment_activity: trim long publisher_id/dedup caveat — moved out of the first sample's docstring to keep it approachable --- workflow_streams/README.md | 19 ++++++++++++++++++- .../activities/payment_activity.py | 10 ---------- workflow_streams/workflows/hub_workflow.py | 3 --- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/workflow_streams/README.md b/workflow_streams/README.md index 1d6f167c..ef983740 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -16,7 +16,7 @@ signals and subscribe via long-poll updates. This packages the boilerplate — batching, offset tracking, topic filtering, continue-as-new hand-off — into a reusable stream. -This directory has two scenarios sharing one Worker. +This directory has four scenarios sharing one Worker. **Scenario 1 — basic publish/subscribe with heterogeneous topics:** @@ -114,3 +114,20 @@ are continuous across the disconnect — no events lost, none duplicated): workflow result: pipeline workflow-stream-pipeline-... done ``` + +## Notes + +* **Subscriber start position.** `subscribe(...)` without `from_offset` + starts at the stream's current base offset and follows live — older + events that have been truncated, or that arrived before the + subscribe call, are not replayed. Pass `from_offset=N` to resume + from a known position (see `run_reconnecting_subscriber.py`); the + iterator skips forward to the current base if `N` has been + truncated. +* **Continue-as-new.** Every `*Input` dataclass carries + `stream_state: WorkflowStreamState | None = None`. To survive + continue-as-new without losing buffered items, capture the workflow's + stream state and pass it to the next run via + `WorkflowStream(prior_state=...)` in `@workflow.init`. The samples + declare the field for completeness; none of them actually trigger + continue-as-new. diff --git a/workflow_streams/activities/payment_activity.py b/workflow_streams/activities/payment_activity.py index d94a071b..2ccd708b 100644 --- a/workflow_streams/activities/payment_activity.py +++ b/workflow_streams/activities/payment_activity.py @@ -16,16 +16,6 @@ async def charge_card(order_id: str) -> str: `WorkflowStreamClient.from_within_activity()` reads the parent workflow id and the Temporal client from the activity context, so this activity can push events back without any wiring. - - Caveat: each call to ``from_within_activity()`` creates a fresh - client with a random ``publisher_id``, so dedup does not protect - against an activity retry republishing the same events. For - activities that must be exactly-once on the stream side, derive a - stable ``publisher_id`` from ``activity.info().activity_id`` (this - is invariant across attempts of the same scheduled activity). The - current ``WorkflowStreamClient`` API does not yet expose - ``publisher_id`` on its constructors; this sample accepts - at-most-once-per-attempt semantics. """ client = WorkflowStreamClient.from_within_activity( batch_interval=timedelta(milliseconds=200) diff --git a/workflow_streams/workflows/hub_workflow.py b/workflow_streams/workflows/hub_workflow.py index fdf7da56..1903b20a 100644 --- a/workflow_streams/workflows/hub_workflow.py +++ b/workflow_streams/workflows/hub_workflow.py @@ -30,7 +30,4 @@ async def run(self, input: HubInput) -> str: @workflow.signal def close(self) -> None: - # Custom signal handler that does not read stream state, so the - # synchronous-handler race documented in the README does not - # apply. self._closed = True From 62946911b388de3fa4b98efa72ef1d6757d7c316 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 30 Apr 2026 07:09:04 +0000 Subject: [PATCH 07/23] workflow_streams: deliver terminal events + fix run_publisher subscribe shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end runs of the four workflow_streams scenarios surfaced two sample-side issues, both fixed here. run_publisher's consumer asserted ``isinstance(item.data, Payload)`` and called ``payload_converter.from_payload(item.data, T)``. The contrib's ``subscribe()`` defaults to converter-decoded data, not raw payloads, so this assertion fired on the first run. Switch to ``result_type=RawValue`` (the documented escape hatch for heterogeneous topics) and read ``item.data.payload``. Items published in the same workflow task that returns from ``@workflow.run`` were not delivered to subscribers — the in-memory log dies with the workflow and the next subscriber poll lands on a completed workflow. Fix: each scenario now uses an in-band terminator that subscribers break on, and each workflow holds the run open with ``await workflow.sleep(timedelta(milliseconds=500))`` so that final publish is fetched before the workflow exits: - OrderWorkflow / PipelineWorkflow: the workflow's own ``StatusEvent(kind="complete")`` / ``StageEvent(stage="complete")`` is the terminator (consumers already broke on it). - HubWorkflow: the *publisher* in run_external_publisher emits a sentinel ``NewsEvent(headline="__done__")`` immediately before signaling close; the consumer breaks on the sentinel. - TickerWorkflow: the final tick (n == count - 1) is the terminator; ``keep_last`` guarantees that offset survives the last truncation, so even slow consumers reach it. Because subscribers stop polling on the terminator, by the time ``workflow.run`` returns there are no in-flight poll handlers — no ``UnfinishedUpdateHandlersWarning`` from the SDK and no need for ``detach_pollers()`` / ``wait_condition(all_handlers_finished)`` in the workflow exit path. Two consecutive end-to-end runs of all four scenarios pass cleanly against ``temporal server start-dev --headless``. --- workflow_streams/run_external_publisher.py | 14 ++++++++++++-- workflow_streams/run_publisher.py | 16 +++++++++------- workflow_streams/run_truncating_ticker.py | 14 ++++++++++---- workflow_streams/workflows/hub_workflow.py | 7 +++++++ workflow_streams/workflows/order_workflow.py | 5 +++++ workflow_streams/workflows/pipeline_workflow.py | 4 ++++ workflow_streams/workflows/ticker_workflow.py | 6 ++++++ 7 files changed, 53 insertions(+), 13 deletions(-) diff --git a/workflow_streams/run_external_publisher.py b/workflow_streams/run_external_publisher.py index 1663ed31..bf7d98e6 100644 --- a/workflow_streams/run_external_publisher.py +++ b/workflow_streams/run_external_publisher.py @@ -44,6 +44,13 @@ "regulator opens probe", ] +# In-band terminator the publisher emits before signaling close. The +# subscriber recognizes this value and stops polling — without an +# explicit terminator the consumer would have to rely on the workflow +# returning to break the iterator, which means racing the last item +# delivery against workflow completion. +DONE_HEADLINE = "__done__" + async def main() -> None: client = await Client.connect("localhost:7233") @@ -69,9 +76,10 @@ async def publish_news() -> None: news.publish(NewsEvent(headline=headline)) print(f"[publisher] sent: {headline}") await asyncio.sleep(0.5) + news.publish(NewsEvent(headline=DONE_HEADLINE), force_flush=True) await producer.flush() - # Tell the hub it can stop. The workflow's run() returns, and - # any in-flight subscribers see their async-for loop exit. + # Tell the hub it can stop. The subscriber has already broken + # out of its async-for loop on the sentinel above. await handle.signal(HubWorkflow.close) print("[publisher] signaled close") @@ -80,6 +88,8 @@ async def consume_news() -> None: async for item in consumer.subscribe( [TOPIC_NEWS], result_type=NewsEvent ): + if item.data.headline == DONE_HEADLINE: + return print(f"[subscriber] offset={item.offset}: {item.data.headline}") await asyncio.gather(publish_news(), consume_news()) diff --git a/workflow_streams/run_publisher.py b/workflow_streams/run_publisher.py index 85c967ee..2e5ddb8d 100644 --- a/workflow_streams/run_publisher.py +++ b/workflow_streams/run_publisher.py @@ -3,8 +3,8 @@ import asyncio import uuid -from temporalio.api.common.v1 import Payload from temporalio.client import Client +from temporalio.common import RawValue from temporalio.contrib.workflow_streams import WorkflowStreamClient from workflow_streams.shared import ( @@ -35,17 +35,19 @@ async def main() -> None: async def consume() -> None: # Single iterator over both topics — avoids a cancellation race - # between two concurrent subscribers. result_type is left unset - # so we can dispatch heterogeneous events on item.topic. - async for item in stream.subscribe([TOPIC_STATUS, TOPIC_PROGRESS]): - assert isinstance(item.data, Payload) + # between two concurrent subscribers. result_type=RawValue + # delivers the underlying Payload so we can dispatch + # heterogeneous events on item.topic. + async for item in stream.subscribe( + [TOPIC_STATUS, TOPIC_PROGRESS], result_type=RawValue + ): if item.topic == TOPIC_STATUS: - evt = converter.from_payload(item.data, StatusEvent) + evt = converter.from_payload(item.data.payload, StatusEvent) print(f"[status] {evt.kind}: order={evt.order_id}") if evt.kind == "complete": return elif item.topic == TOPIC_PROGRESS: - progress = converter.from_payload(item.data, ProgressEvent) + progress = converter.from_payload(item.data.payload, ProgressEvent) print(f"[progress] {progress.message}") result = await race_with_workflow(consume(), handle) diff --git a/workflow_streams/run_truncating_ticker.py b/workflow_streams/run_truncating_ticker.py index 50876a0d..65f8740e 100644 --- a/workflow_streams/run_truncating_ticker.py +++ b/workflow_streams/run_truncating_ticker.py @@ -41,6 +41,7 @@ SLOW_SUBSCRIBER_DELAY_S = 1.5 +TICKER_COUNT = 20 async def main() -> None: @@ -50,7 +51,7 @@ async def main() -> None: handle = await client.start_workflow( TickerWorkflow.run, TickerInput( - count=20, + count=TICKER_COUNT, keep_last=3, truncate_every=5, interval_ms=400, @@ -60,19 +61,24 @@ async def main() -> None: ) stream = WorkflowStreamClient.create(client, workflow_id) + last_n = TICKER_COUNT - 1 + # Both subscribers break on the final tick (n == last_n). ``keep_last`` + # ensures that offset survives the last truncation so even the slow + # consumer reaches it. async def fast_subscriber() -> None: async for item in stream.subscribe([TOPIC_TICK], result_type=TickEvent): print(f"[fast] offset={item.offset:3d} n={item.data.n}") + if item.data.n == last_n: + return async def slow_subscriber() -> None: async for item in stream.subscribe([TOPIC_TICK], result_type=TickEvent): print(f"[SLOW] offset={item.offset:3d} n={item.data.n}") + if item.data.n == last_n: + return await asyncio.sleep(SLOW_SUBSCRIBER_DELAY_S) - # Both iterators exit normally when the workflow completes. No - # terminal sentinel is needed — see the doc's "When the Workflow - # run completes" note. await asyncio.gather(fast_subscriber(), slow_subscriber()) result = await handle.result() diff --git a/workflow_streams/workflows/hub_workflow.py b/workflow_streams/workflows/hub_workflow.py index 1903b20a..5dcc3c5f 100644 --- a/workflow_streams/workflows/hub_workflow.py +++ b/workflow_streams/workflows/hub_workflow.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import timedelta + from temporalio import workflow from temporalio.contrib.workflow_streams import WorkflowStream @@ -26,6 +28,11 @@ def __init__(self, input: HubInput) -> None: @workflow.run async def run(self, input: HubInput) -> str: await workflow.wait_condition(lambda: self._closed) + # The publisher publishes its own terminator into the stream + # before signaling close (see run_external_publisher.py). + # Hold the run open briefly so subscribers' final poll + # delivers any items still in the log. + await workflow.sleep(timedelta(milliseconds=500)) return f"hub {input.hub_id} closed" @workflow.signal diff --git a/workflow_streams/workflows/order_workflow.py b/workflow_streams/workflows/order_workflow.py index 8b944508..099634cd 100644 --- a/workflow_streams/workflows/order_workflow.py +++ b/workflow_streams/workflows/order_workflow.py @@ -50,4 +50,9 @@ async def run(self, input: OrderInput) -> str: self.status.publish(StatusEvent(kind="shipped", order_id=input.order_id)) self.progress.publish(ProgressEvent(message=f"charge id: {charge_id}")) self.status.publish(StatusEvent(kind="complete", order_id=input.order_id)) + # The "complete" status event above is the in-band terminator + # subscribers break on (see run_publisher.py). Hold the run + # open briefly so subscribers' next poll delivers it before + # this task returns and the in-memory log is gone. + await workflow.sleep(timedelta(milliseconds=500)) return charge_id diff --git a/workflow_streams/workflows/pipeline_workflow.py b/workflow_streams/workflows/pipeline_workflow.py index a2d96d95..83336905 100644 --- a/workflow_streams/workflows/pipeline_workflow.py +++ b/workflow_streams/workflows/pipeline_workflow.py @@ -41,4 +41,8 @@ async def run(self, input: PipelineInput) -> str: self.status.publish(StageEvent(stage=stage)) if stage != "complete": await workflow.sleep(timedelta(seconds=2)) + # The "complete" stage above is the in-band terminator + # subscribers break on. Hold the run open briefly so the final + # poll delivers it. + await workflow.sleep(timedelta(milliseconds=500)) return f"pipeline {input.pipeline_id} done" diff --git a/workflow_streams/workflows/ticker_workflow.py b/workflow_streams/workflows/ticker_workflow.py index e11616b4..566b98f1 100644 --- a/workflow_streams/workflows/ticker_workflow.py +++ b/workflow_streams/workflows/ticker_workflow.py @@ -57,4 +57,10 @@ async def run(self, input: TickerInput) -> str: # Drop everything except the last `keep_last` entries. truncate_to = self._published - input.keep_last self.stream.truncate(truncate_to) + # The final tick (n == count - 1) is the in-band terminator + # subscribers break on. ``keep_last`` guarantees that final + # offset survives the last truncation so even slow consumers + # eventually see it. Hold the run open briefly so the final + # poll delivers it. + await workflow.sleep(timedelta(milliseconds=500)) return f"ticker emitted {self._published} events" From bfbb2ed19f984cb4840be3247e5eedeca84abc4e Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Thu, 30 Apr 2026 07:11:25 +0000 Subject: [PATCH 08/23] workflow_streams README: document the stream-end pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribers don't exit on their own when the host workflow completes — they need an in-band terminator, and the workflow needs to hold open briefly so the final publish is fetched before run() returns. Both pieces show up in every scenario here, so document them in one place and update scenario 3's description to mention the sentinel headline the publisher emits. --- workflow_streams/README.md | 47 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/workflow_streams/README.md b/workflow_streams/README.md index ef983740..bf2466fb 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -48,11 +48,12 @@ This directory has four scenarios sharing one Worker. * `run_external_publisher.py` — starts the hub, then publishes events into it from a plain Python coroutine using `WorkflowStreamClient.create(client, workflow_id)`. A subscriber - task runs alongside; when the publisher is done it signals - `HubWorkflow.close`, the workflow's run finishes, and the - subscriber's iterator exits normally. This is the shape that fits a - backend service or scheduled job pushing events into a workflow it - didn't itself start. + task runs alongside; when the publisher is done it emits an in-band + sentinel headline (`__done__`) into the stream, then signals + `HubWorkflow.close`. The subscriber breaks on the sentinel and + exits its `async for`. This is the shape that fits a backend + service or scheduled job pushing events into a workflow it didn't + itself start. **Scenario 4 — bounded log via `truncate()`:** @@ -69,6 +70,42 @@ This directory has four scenarios sharing one Worker. `run_worker.py` registers all four workflows and the activity. +## Ending the stream + +`WorkflowStreamClient.subscribe()` is a long-poll loop — it does not +exit on its own when the host workflow completes. Two things have to +happen at the end of a streamed workflow for clean shutdown: + +1. **An in-band terminator that subscribers recognize.** Each scenario + here sends one before the workflow exits: + - `OrderWorkflow` and `PipelineWorkflow` publish a "complete" + status / stage event; consumers break on it. + - `run_external_publisher.py` publishes a sentinel + `NewsEvent(headline="__done__")` immediately before signaling + `HubWorkflow.close`; the consumer breaks on the sentinel. + - `TickerWorkflow`'s final tick (`n == count - 1`) is the + terminator; subscribers break when they see it. `keep_last` + guarantees that final offset survives the last truncation, so + even slow consumers reach it. + +2. **A short hold-open in the workflow before returning** so that the + final publish gets fetched. Items published in the same workflow + task that returns from `@workflow.run` are abandoned: the + in-memory log dies with the workflow, and the next subscriber + poll lands on a completed workflow. Each workflow here ends with + + ```python + await workflow.sleep(timedelta(milliseconds=500)) + return ... + ``` + + which gives subscribers in their `poll_cooldown` interval time to + issue one more poll. With both pieces in place, subscribers + receive the terminator, break out of their `async for`, and stop + polling — by the time the workflow exits there are no in-flight + poll handlers, so the SDK does not warn about unfinished + handlers. + ## Run it ```bash From 09623793346e91ac8bd048aeae170e566bf0c4d3 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 17:17:59 -0700 Subject: [PATCH 09/23] samples: workflow_streams: README and wheel packages cleanup Now that temporalio 1.27.0 has shipped (and main has bumped to it in #302), drop the README's "install sdk-python from a branch" callout and point at >=1.27.0 instead. Also add workflow_streams to the wheel packages list alongside the other samples. --- pyproject.toml | 1 + workflow_streams/README.md | 12 ++++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 404f46ad..0ec70664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ packages = [ "updatable_timer", "worker_specific_task_queues", "worker_versioning", + "workflow_streams", ] [tool.hatch.build.targets.wheel.sources] diff --git a/workflow_streams/README.md b/workflow_streams/README.md index bf2466fb..8cad25b3 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -1,13 +1,9 @@ # Workflow Streams -> **Experimental.** These samples target the -> `temporalio.contrib.workflow_streams` module on the -> [`contrib/pubsub` branch of sdk-python][branch], which is not yet -> released. To run them locally, install sdk-python from that branch -> (e.g. `uv pip install -e ` after checking out the -> branch). - -[branch]: https://github.com/temporalio/sdk-python/tree/contrib/pubsub +> **Experimental.** These samples use +> `temporalio.contrib.workflow_streams`, which ships in +> `temporalio>=1.27.0`. The module is considered experimental and its +> API may change in future versions. `temporalio.contrib.workflow_streams` lets a workflow host a durable, offset-addressed event channel. The workflow holds an append-only log; From d5cc2fe6ce0fd3dec7dc2fe8ec6396577abf0e27 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 17:40:06 -0700 Subject: [PATCH 10/23] samples: workflow_streams: drop force_flush=True from charge_card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity's final publish was using force_flush=True, which sets the flush_event so the background flusher fires immediately. Triggering a flush right before __aexit__ runs the activity into the WorkflowStreamClient's cancel-mid-flush path: __aexit__ cancels the flusher task while it's awaiting the publish signal RPC, the cancel propagates into the in-flight signal, and the activity hangs until the StartToClose timeout fires. Empirically the workflow then retries the activity indefinitely. Without force_flush=True the buffered "card charged" event flushes via the regular 200ms batch interval and the flusher is sleeping in wait_for(...) when __aexit__ cancels it — a clean cancellation path. The user-visible publish ordering is unchanged. The underlying SDK bug should be fixed separately by switching __aexit__ from cancel() to a cooperative-stop flag so the in-flight signal completes before the flusher exits. --- workflow_streams/activities/payment_activity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/workflow_streams/activities/payment_activity.py b/workflow_streams/activities/payment_activity.py index 2ccd708b..b3b2aa29 100644 --- a/workflow_streams/activities/payment_activity.py +++ b/workflow_streams/activities/payment_activity.py @@ -26,6 +26,5 @@ async def charge_card(order_id: str) -> str: await asyncio.sleep(1.0) progress.publish( ProgressEvent(message="card charged"), - force_flush=True, ) return f"charge-{order_id}" From 553bfdbcd81bc2a06ac9f9d95dc1e7d39a111a1e Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 17:53:21 -0700 Subject: [PATCH 11/23] samples: workflow_streams: drop temp-file resume offset; add stats column The reconnecting-subscriber demo previously persisted its resume offset to a temp file between phases. Inside one process that's theatrical: the disconnect/reconnect shape comes from creating a fresh Client + WorkflowStreamClient with from_offset=N, not from where N happens to be stored. Replace the file with a local int and a comment about durable storage in production (a DB row keyed by user_id/run_id, etc.). Restructure output around a stats column so the demo conveys what's happening to the stream at all times, not just between phases. A background poller calls WorkflowStreamClient.get_offset() throughout and emits a heartbeat line once a second; every emit prints current proc/avail/pend in a left column followed by the phase or event message. Watching pend grow during the disconnect window and shrink again as phase 2 catches up is the demo's core point. --- workflow_streams/README.md | 33 ++-- .../run_reconnecting_subscriber.py | 170 ++++++++++++------ 2 files changed, 134 insertions(+), 69 deletions(-) diff --git a/workflow_streams/README.md b/workflow_streams/README.md index 8cad25b3..8ef70497 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -130,22 +130,27 @@ Expected output on the basic publisher side: workflow result: charge-order-1 ``` -Expected output on the reconnecting subscriber side (note the offsets -are continuous across the disconnect — no events lost, none duplicated): +Expected output on the reconnecting subscriber side. Each line carries +a stats column on the left (`proc`, `avail`, `pend`) and a phase / +event message on the right; a background poller emits a `·` heartbeat +once a second. Offsets are continuous across the disconnect — no +events lost, none duplicated: ``` -[phase 1] connecting and reading first few events - offset= 0 stage=validating - offset= 1 stage=loading data -[phase 1] persisted resume offset=2 -> /tmp/...; disconnecting - -[phase 2] reconnecting and resuming from persisted offset - offset= 2 stage=transforming - offset= 3 stage=writing output - offset= 4 stage=verifying - offset= 5 stage=complete - -workflow result: pipeline workflow-stream-pipeline-... done +proc= 0 avail= 0 pend= 0 │ started workflow-stream-pipeline-... +proc= 0 avail= 1 pend= 1 │ [phase 1] connecting +proc= 1 avail= 1 pend= 0 │ offset= 0 stage=validating +proc= 2 avail= 2 pend= 0 │ offset= 1 stage=loading data +proc= 2 avail= 2 pend= 0 │ [phase 1] disconnecting +proc= 2 avail= 3 pend= 1 │ · +proc= 2 avail= 3 pend= 1 │ · +proc= 2 avail= 4 pend= 2 │ · +proc= 2 avail= 4 pend= 2 │ [phase 2] reconnecting +proc= 3 avail= 4 pend= 1 │ offset= 2 stage=transforming +proc= 4 avail= 4 pend= 0 │ offset= 3 stage=writing output +proc= 5 avail= 5 pend= 0 │ offset= 4 stage=verifying +proc= 6 avail= 6 pend= 0 │ offset= 5 stage=complete +proc= 6 avail= 6 pend= 0 │ workflow result: pipeline ... done ``` ## Notes diff --git a/workflow_streams/run_reconnecting_subscriber.py b/workflow_streams/run_reconnecting_subscriber.py index 3aae76c6..ee86c965 100644 --- a/workflow_streams/run_reconnecting_subscriber.py +++ b/workflow_streams/run_reconnecting_subscriber.py @@ -1,4 +1,4 @@ -"""Reconnecting subscriber: persist offset, disconnect, resume. +"""Reconnecting subscriber: read a few events, disconnect, resume. Demonstrates the central Workflow Streams use case: a consumer can disappear mid-stream — page refresh, server restart, laptop closed — @@ -8,7 +8,15 @@ The script runs the pattern in two phases inside one process to keep the demo short. The same code shape works across actual process -restarts because the resume offset is persisted to disk between phases. +restarts because the resume offset is durable in the workflow, not in +the consumer. + +Output is one line per emit, with current stream stats in a left column +and a phase / event message in a right column. A background poller +calls ``WorkflowStreamClient.get_offset()`` for the whole demo and +emits a heartbeat line once a second so you can watch ``pending`` +(``available - processed``) grow while the consumer is disconnected +and shrink as phase 2 catches up. Run the worker first (``uv run workflow_streams/run_worker.py``), then:: @@ -18,9 +26,8 @@ from __future__ import annotations import asyncio -import tempfile import uuid -from pathlib import Path +from dataclasses import dataclass from temporalio.client import Client from temporalio.contrib.workflow_streams import WorkflowStreamClient @@ -37,6 +44,36 @@ # Picked small enough that the workflow is still running after. PHASE_1_EVENTS = 2 +# How long to stay disconnected. +DISCONNECT_SECONDS = 3.0 + +# Background poller cadence. The poller refreshes state.available this +# often and emits a heartbeat line once per HEARTBEAT_SECONDS. +POLL_INTERVAL_SECONDS = 0.25 +HEARTBEAT_SECONDS = 1.0 + +# Width of the stats column. Picked to fit the longest stats string. +LEFT_WIDTH = 30 + + +@dataclass +class State: + processed: int = 0 + available: int = 0 + + @property + def pending(self) -> int: + return max(0, self.available - self.processed) + + +def emit(state: State, message: str) -> None: + left = ( + f"proc={state.processed:>2} " + f"avail={state.available:>2} " + f"pend={state.pending:>2}" + ) + print(f"{left:<{LEFT_WIDTH}}│ {message}", flush=True) + async def main() -> None: client = await Client.connect("localhost:7233") @@ -49,58 +86,81 @@ async def main() -> None: task_queue=TASK_QUEUE, ) - # Where the consumer remembers its position. In a real BFF or UI - # backend this would be a database row keyed by (user_id, run_id); - # a temp file keeps the sample self-contained. - offset_path = Path(tempfile.gettempdir()) / f"{workflow_id}.offset" - - # ---- Phase 1: connect, read a couple of events, persist offset, disconnect. - print("[phase 1] connecting and reading first few events") + # In a real BFF the resume offset lives in durable storage keyed by + # (user_id, run_id) — a database row, a Redis key, etc. For an + # in-process demo a State.processed attribute works the same way. + state = State() stream = WorkflowStreamClient.create(client, workflow_id) - seen = 0 - next_offset = 0 - async for item in stream.subscribe([TOPIC_STATUS], result_type=StageEvent): - print(f" offset={item.offset:2d} stage={item.data.stage}") - # Persist *one past* the offset just consumed. On resume we want - # the *next* unseen event, not the one we already showed. - next_offset = item.offset + 1 - offset_path.write_text(str(next_offset)) - seen += 1 - if seen >= PHASE_1_EVENTS: - break - - print( - f"[phase 1] persisted resume offset={next_offset} -> {offset_path}; disconnecting\n" - ) - # The async for loop exits the subscribe() iterator. Any background - # poll Update is cancelled. The workflow keeps running in the - # background, accumulating events into its log. - await asyncio.sleep(3) # let the workflow publish more in our absence - - # ---- Phase 2: reconnect, read persisted offset, resume from there. - print("[phase 2] reconnecting and resuming from persisted offset") - resume_from = int(offset_path.read_text()) - # A brand-new client and stream object — same shape as a different - # process picking up where the first one left off. - client2 = await Client.connect("localhost:7233") - stream2 = WorkflowStreamClient.create(client2, workflow_id) - async for item in stream2.subscribe( - [TOPIC_STATUS], - from_offset=resume_from, - result_type=StageEvent, - ): - print(f" offset={item.offset:2d} stage={item.data.stage}") - # Continue persisting after each event so a second crash here - # would also resume cleanly. - offset_path.write_text(str(item.offset + 1)) - if item.data.stage == "complete": - break - - result = await handle.result() - print(f"\nworkflow result: {result}") - # Clean up the offset file; in a real consumer you'd retain it as - # long as the user might reconnect. - offset_path.unlink(missing_ok=True) + emit(state, f"started {workflow_id}") + + stop = asyncio.Event() + + async def poller() -> None: + """Refresh state.available; emit a heartbeat line once a second.""" + loop = asyncio.get_running_loop() + last_emit = loop.time() + while not stop.is_set(): + try: + state.available = await stream.get_offset() + except Exception: + pass + now = loop.time() + if now - last_emit >= HEARTBEAT_SECONDS: + emit(state, "·") + last_emit = now + try: + await asyncio.wait_for( + stop.wait(), timeout=POLL_INTERVAL_SECONDS + ) + except asyncio.TimeoutError: + pass + + poller_task = asyncio.create_task(poller()) + try: + # ---- Phase 1: connect, read a couple of events, "disconnect". + emit(state, "[phase 1] connecting") + seen = 0 + async for item in stream.subscribe( + [TOPIC_STATUS], result_type=StageEvent + ): + # Remember *one past* the offset just consumed: on resume we + # want the next unseen event, not the one we already showed. + state.processed = item.offset + 1 + emit(state, f" offset={item.offset:2d} stage={item.data.stage}") + seen += 1 + if seen >= PHASE_1_EVENTS: + break + emit(state, "[phase 1] disconnecting") + + # ---- Disconnect window: nobody reads. The workflow keeps + # publishing — `pend` grows on the heartbeat lines as the offset + # advances past `processed`. + await asyncio.sleep(DISCONNECT_SECONDS) + + # ---- Phase 2: brand-new client + stream, resume from saved + # offset. Same shape as a different process picking up where the + # first one left off. + emit(state, "[phase 2] reconnecting") + client2 = await Client.connect("localhost:7233") + stream2 = WorkflowStreamClient.create(client2, workflow_id) + async for item in stream2.subscribe( + [TOPIC_STATUS], + from_offset=state.processed, + result_type=StageEvent, + ): + state.processed = item.offset + 1 + emit(state, f" offset={item.offset:2d} stage={item.data.stage}") + if item.data.stage == "complete": + break + + result = await handle.result() + emit(state, f"workflow result: {result}") + finally: + stop.set() + try: + await poller_task + except asyncio.CancelledError: + pass if __name__ == "__main__": From c107687158b17012b40629bc0dbc7de5ea3ceb22 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 18:25:02 -0700 Subject: [PATCH 12/23] samples: workflow_streams: surface multiple truncation jumps in ticker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truncating-ticker demo is meant to make the bounded-log trade visible: fast subscriber sees every event, slow subscriber loses intermediate ones to truncation. The previous parameters (truncate_every=5, keep_last=3, interval_ms=400, slow_delay=1.5s) produced at most one tiny jump near the end of the run — easy to miss. Tighter parameters (truncate_every=2, keep_last=1, interval_ms=200, count=30) keep the workflow log at one or two entries between truncations. That shrinks the slow subscriber's per-poll batch, so it re-polls more often, and most polls land after a truncation that has passed its position. The result is several visible jumps over the demo, not a single batched one at the end. Switch the output to two lanes (fast on the left, slow on the right with explicit "↪ jumped offset=N → M (K dropped)" markers) so the divergence reads at a glance instead of being lost in interleaved single-stream output. Also extend the docstring to call out the opposite trade — never truncating means slow consumers eventually catch up at the cost of unbounded workflow history — so readers know when this pattern is the wrong fit. --- workflow_streams/run_truncating_ticker.py | 93 ++++++++++++++++------- 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/workflow_streams/run_truncating_ticker.py b/workflow_streams/run_truncating_ticker.py index 65f8740e..555392e6 100644 --- a/workflow_streams/run_truncating_ticker.py +++ b/workflow_streams/run_truncating_ticker.py @@ -3,20 +3,26 @@ The ``TickerWorkflow`` publishes ``count`` events at a fixed interval, calling ``self.stream.truncate(...)`` periodically to bound log growth. This script subscribes twice — once fast, once slow — and -prints both side-by-side so the trade is visible: - -* The fast subscriber keeps up and sees every published offset in - order. -* The slow subscriber sleeps between iterations. When a truncation - runs past its position, the iterator silently jumps forward to the - new base offset — the slow subscriber's offsets jump too, and - intermediate events are not visible to it. - -This is the bounded-log model: log size is capped, slow consumers may -miss intermediate events, but they always see the most recent state. -For long-running workflows pushing high event volumes this is usually -the right trade — pair with set-semantic events where each event -carries enough state to make missing the prior ones recoverable. +prints them in two lanes so the trade is visible at a glance: + +* **Fast lane** (left). Keeps up. Sees every published offset. +* **Slow lane** (right). Sleeps between iterations. When a truncation + has dropped its position by the time it polls again, the iterator + silently jumps forward to the new base offset; the slow lane prints + a ``↪ jumped N → M (K dropped)`` marker for each gap and resumes + at the new offset. + +``truncate()`` is unilateral: the workflow does not know who is +subscribed and does not wait for them. The implicit alternative — +never truncating — keeps every event around forever, lets slow +consumers eventually catch up without losses, and pays for it in +unbounded workflow history. The truncation model is the opposite +trade: bounded log, at-best-effort delivery to slow consumers, no +backpressure on the publisher. Pair it with set-semantic events where +each event carries enough state to make missing the prior ones +recoverable. (If you actually need lossless delivery to slow +consumers, the workflow has to coordinate acknowledgements +explicitly — that is a different sample.) Run the worker first (``uv run workflow_streams/run_worker.py``), then:: @@ -39,9 +45,37 @@ ) from workflow_streams.workflows.ticker_workflow import TickerWorkflow - +# Aggressive truncation so the log stays at most KEEP_LAST entries +# right after each truncation, which keeps the slow subscriber's +# per-poll batch tiny. Small batches + a slow per-event sleep mean the +# slow subscriber re-polls often, and most of those polls land after a +# truncation that has passed its position — so it sees several jumps +# during the run rather than one batched at the end. +TICKER_COUNT = 30 +INTERVAL_MS = 200 +TRUNCATE_EVERY = 2 +KEEP_LAST = 1 SLOW_SUBSCRIBER_DELAY_S = 1.5 -TICKER_COUNT = 20 + +LANE_WIDTH = 32 +SEP = "│" + + +def emit_fast(message: str) -> None: + print(f"{message:<{LANE_WIDTH}} {SEP}", flush=True) + + +def emit_slow(message: str) -> None: + print(f"{' ' * LANE_WIDTH} {SEP} {message}", flush=True) + + +def emit_header() -> None: + rule = "─" * LANE_WIDTH + print( + f"{'fast (every event)':<{LANE_WIDTH}} {SEP} " + f"slow (sleeps {SLOW_SUBSCRIBER_DELAY_S}s between events)" + ) + print(f"{rule} {SEP} {rule}") async def main() -> None: @@ -52,29 +86,35 @@ async def main() -> None: TickerWorkflow.run, TickerInput( count=TICKER_COUNT, - keep_last=3, - truncate_every=5, - interval_ms=400, + keep_last=KEEP_LAST, + truncate_every=TRUNCATE_EVERY, + interval_ms=INTERVAL_MS, ), id=workflow_id, task_queue=TASK_QUEUE, ) - stream = WorkflowStreamClient.create(client, workflow_id) last_n = TICKER_COUNT - 1 - # Both subscribers break on the final tick (n == last_n). ``keep_last`` - # ensures that offset survives the last truncation so even the slow - # consumer reaches it. + emit_header() + async def fast_subscriber() -> None: async for item in stream.subscribe([TOPIC_TICK], result_type=TickEvent): - print(f"[fast] offset={item.offset:3d} n={item.data.n}") + emit_fast(f"offset={item.offset:>3} n={item.data.n}") if item.data.n == last_n: return async def slow_subscriber() -> None: + last_offset = -1 async for item in stream.subscribe([TOPIC_TICK], result_type=TickEvent): - print(f"[SLOW] offset={item.offset:3d} n={item.data.n}") + if last_offset >= 0 and item.offset > last_offset + 1: + gap = item.offset - last_offset - 1 + emit_slow( + f"↪ jumped offset={last_offset} → {item.offset} " + f"({gap} dropped)" + ) + emit_slow(f"offset={item.offset:>3} n={item.data.n}") + last_offset = item.offset if item.data.n == last_n: return await asyncio.sleep(SLOW_SUBSCRIBER_DELAY_S) @@ -82,7 +122,8 @@ async def slow_subscriber() -> None: await asyncio.gather(fast_subscriber(), slow_subscriber()) result = await handle.result() - print(f"\nworkflow result: {result}") + print() + print(f"workflow result: {result}") if __name__ == "__main__": From 31b6cf0affca0aac32c5440ae5e2c02da4435f51 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 18:39:47 -0700 Subject: [PATCH 13/23] samples: workflow_streams: add LLM-streaming scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fifth scenario to workflow_streams/ that streams an OpenAI chat completion to the terminal through a Workflow Stream. Activity is the publisher (it owns the non-deterministic API call), workflow hosts the stream and runs the activity, runner subscribes and renders to stdout as deltas arrive. Layout: * `chat_shared.py` — types and topics for this scenario, kept out of the cross-scenario `shared.py` because no other scenario uses them * `workflows/chat_workflow.py` — `ChatWorkflow` runs `stream_completion` with `RetryPolicy(maximum_attempts=3)` and the same 500ms hold-open pattern the other four samples use * `activities/chat_activity.py` — `stream_completion` calls `AsyncOpenAI(...).chat.completions.create(stream=True)` with `gpt-5-mini`, publishes each token chunk on the `delta` topic, the full text on `complete`, and a `RetryEvent` on `retry` when running on attempt > 1. `force_flush=True` is intentionally omitted to avoid the `__aexit__` cancel-mid-flight hang in `temporalio.contrib.workflow_streams` 1.27.0; the 200ms `batch_interval` is fast enough for an interactive feel. * `run_chat.py` — subscribes to all three topics, prints deltas to stdout as they stream, and on a retry event uses plain ANSI escapes (`\033[A`, `\033[J`) to rewind the rendered output before the retried attempt re-publishes * `run_chat_worker.py` — runs on its own task queue (`workflow-stream-chat-task-queue`), registering only `ChatWorkflow` and `stream_completion`; the openai dependency and the `OPENAI_API_KEY` requirement stay isolated to this one scenario The split worker also makes the retry-handling demo trivial to run: the user kills the chat worker mid-stream, brings it back up, and the activity retries — no synthetic failure injection needed. Adds `chat-stream = ["openai>=1.0,<2"]` as a new optional dependency group; `uv sync --group chat-stream` and an `OPENAI_API_KEY` are documented in the README. --- pyproject.toml | 1 + workflow_streams/README.md | 55 ++++++++- workflow_streams/activities/chat_activity.py | 74 ++++++++++++ workflow_streams/chat_shared.py | 44 +++++++ workflow_streams/run_chat.py | 121 +++++++++++++++++++ workflow_streams/run_chat_worker.py | 40 ++++++ workflow_streams/workflows/chat_workflow.py | 51 ++++++++ 7 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 workflow_streams/activities/chat_activity.py create mode 100644 workflow_streams/chat_shared.py create mode 100644 workflow_streams/run_chat.py create mode 100644 workflow_streams/run_chat_worker.py create mode 100644 workflow_streams/workflows/chat_workflow.py diff --git a/pyproject.toml b/pyproject.toml index 0ec70664..db2e72c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ openai-agents = [ pydantic-converter = ["pydantic>=2.10.6,<3"] sentry = ["sentry-sdk>=2.13.0"] trio-async = ["trio>=0.28.0,<0.29", "trio-asyncio>=0.15.0,<0.16"] +chat-stream = ["openai>=1.0,<2"] cloud-export-to-parquet = [ "pandas>=2.2.2,<3 ; python_version >= '3.10' and python_version < '4.0'", "numpy>=1.26.0,<2 ; python_version >= '3.10' and python_version < '3.13'", diff --git a/workflow_streams/README.md b/workflow_streams/README.md index 8ef70497..aa8e84d5 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -12,7 +12,10 @@ signals and subscribe via long-poll updates. This packages the boilerplate — batching, offset tracking, topic filtering, continue-as-new hand-off — into a reusable stream. -This directory has four scenarios sharing one Worker. +This directory has four scenarios sharing one Worker, plus a fifth +LLM-streaming scenario on its own Worker (see +[Scenario 5 — LLM streaming](#scenario-5--llm-streaming) below for why +it's separate). **Scenario 1 — basic publish/subscribe with heterogeneous topics:** @@ -66,6 +69,56 @@ This directory has four scenarios sharing one Worker. `run_worker.py` registers all four workflows and the activity. +## Scenario 5 — LLM streaming + +* `workflows/chat_workflow.py` — a workflow that hosts a + `WorkflowStream` and runs `stream_completion` as a single activity. + The workflow itself does no streaming; the activity owns the + non-deterministic OpenAI call. +* `activities/chat_activity.py` — calls + `openai.AsyncOpenAI().chat.completions.create(stream=True)`, + publishes each token chunk as a `TextDelta` on the `delta` topic, + the final accumulated text on the `complete` topic, and a + `RetryEvent` on the `retry` topic when running on attempt > 1. +* `run_chat.py` — subscribes to all three topics, renders deltas to + the terminal as they arrive, and on a `retry` event uses ANSI + escapes to rewind the printed output before the retried attempt + starts re-publishing. +* `run_chat_worker.py` — separate worker on its own task queue + (`workflow-stream-chat-task-queue`), registering only `ChatWorkflow` + and `stream_completion`. This isolates the `openai` dependency and + the `OPENAI_API_KEY` requirement to this one scenario. + +This scenario is split out for two reasons. First, it needs an extra +dependency (`openai`) and a secret (`OPENAI_API_KEY`) — putting it on +the main worker would force every other scenario to set up an OpenAI +key. Second, killing the chat worker mid-stream is the easiest way to +demonstrate retry handling, and you don't want the same `Ctrl-C` to +interrupt the other four scenarios' worker. + +Setup: + +```bash +uv sync --group chat-stream +export OPENAI_API_KEY=... +``` + +Run: + +```bash +# Terminal 1: chat worker (its own task queue) +uv run workflow_streams/run_chat_worker.py + +# Terminal 2: +uv run workflow_streams/run_chat.py +``` + +To trigger the retry path, kill the chat worker in Terminal 1 +(`Ctrl-C`) while output is streaming, then start it again. The +activity's next attempt sends a `RetryEvent` first; the consumer +clears its on-screen output via ANSI escapes and re-renders from +scratch. + ## Ending the stream `WorkflowStreamClient.subscribe()` is a long-poll loop — it does not diff --git a/workflow_streams/activities/chat_activity.py b/workflow_streams/activities/chat_activity.py new file mode 100644 index 00000000..0443d0cc --- /dev/null +++ b/workflow_streams/activities/chat_activity.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import timedelta + +from openai import AsyncOpenAI +from temporalio import activity +from temporalio.contrib.workflow_streams import WorkflowStreamClient + +from workflow_streams.chat_shared import ( + TOPIC_COMPLETE, + TOPIC_DELTA, + TOPIC_RETRY, + ChatInput, + RetryEvent, + TextComplete, + TextDelta, +) + + +@activity.defn +async def stream_completion(input: ChatInput) -> str: + """Stream a chat completion to the parent workflow's stream. + + Activity-as-publisher: each delta from the OpenAI streaming API is + pushed to the workflow's stream as a ``TextDelta`` event on the + ``delta`` topic. The accumulated full text returns as the + activity's result and is also published on the ``complete`` topic + as a terminator. On retry attempts (``activity.info().attempt > 1``) + a ``RetryEvent`` lands on the ``retry`` topic before the new + attempt's deltas, so consumers can reset their accumulated state + instead of concatenating the failed attempt's partial output with + the retried attempt's full output. + + No ``force_flush=True``: the 200ms ``batch_interval`` is fast + enough for an interactive feel, and the WorkflowStreamClient's + ``__aexit__`` cancels a sleeping flusher cleanly. (The doc example + uses ``force_flush=True`` on the first delta; that path currently + wedges the activity's exit on a cancel-mid-flight bug — fix is + pending in ``temporalio.contrib.workflow_streams``.) + """ + stream_client = WorkflowStreamClient.from_within_activity( + batch_interval=timedelta(milliseconds=200), + ) + # Disable provider-side retries; let Temporal own retry policy at + # the activity layer. + openai_client = AsyncOpenAI(max_retries=0) + + async with stream_client: + deltas = stream_client.topic(TOPIC_DELTA, type=TextDelta) + complete = stream_client.topic(TOPIC_COMPLETE, type=TextComplete) + retry = stream_client.topic(TOPIC_RETRY, type=RetryEvent) + + attempt = activity.info().attempt + if attempt > 1: + retry.publish(RetryEvent(attempt=attempt)) + + full: list[str] = [] + oai_stream = await openai_client.chat.completions.create( + model=input.model, + messages=[{"role": "user", "content": input.prompt}], + stream=True, + ) + async for chunk in oai_stream: + if not chunk.choices: + continue + text = chunk.choices[0].delta.content + if not text: + continue + deltas.publish(TextDelta(text=text)) + full.append(text) + + full_text = "".join(full) + complete.publish(TextComplete(full_text=full_text)) + return full_text diff --git a/workflow_streams/chat_shared.py b/workflow_streams/chat_shared.py new file mode 100644 index 00000000..88e0de80 --- /dev/null +++ b/workflow_streams/chat_shared.py @@ -0,0 +1,44 @@ +"""Types and constants for the LLM-streaming scenario. + +Kept separate from ``shared.py`` because the other scenarios don't +use these — and the chat scenario runs on its own worker and task +queue so the ``openai`` dependency stays out of everyone else's path. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from temporalio.contrib.workflow_streams import WorkflowStreamState + +# Scenario 5 (LLM streaming) runs on its own worker so the openai +# dependency only matters for that scenario. +CHAT_TASK_QUEUE = "workflow-stream-chat-task-queue" + +# Topics published by the activity. +TOPIC_DELTA = "delta" +TOPIC_COMPLETE = "complete" +TOPIC_RETRY = "retry" + + +@dataclass +class ChatInput: + prompt: str + model: str = "gpt-5-mini" + # Carries stream state across continue-as-new. None on a fresh start. + stream_state: WorkflowStreamState | None = None + + +@dataclass +class TextDelta: + text: str + + +@dataclass +class TextComplete: + full_text: str + + +@dataclass +class RetryEvent: + attempt: int diff --git a/workflow_streams/run_chat.py b/workflow_streams/run_chat.py new file mode 100644 index 00000000..7a4d391c --- /dev/null +++ b/workflow_streams/run_chat.py @@ -0,0 +1,121 @@ +"""Stream LLM output to the terminal, handling retries. + +Starts a ``ChatWorkflow``, subscribes to its delta / complete / retry +topics, and renders the model's output to stdout as it arrives. On a +``RETRY`` event (the activity is on attempt > 1), the consumer rewinds +its rendered output with ANSI escapes and starts fresh — so a killed +worker doesn't leave a half-finished response stuck on screen +followed by the retried attempt's full output. + +Requires ``OPENAI_API_KEY`` in the environment and the ``chat-stream`` +extra:: + + uv sync --group chat-stream + export OPENAI_API_KEY=... + +Run the chat worker first (``uv run workflow_streams/run_chat_worker.py``), +then:: + + uv run workflow_streams/run_chat.py + +To see retry handling in action, kill the chat worker mid-stream +(Ctrl-C in its terminal) and start it again. The consumer will clear +its accumulated output on the ``RETRY`` event and re-render the +retried attempt's output from scratch. +""" + +from __future__ import annotations + +import asyncio +import sys +import uuid + +from temporalio.client import Client +from temporalio.common import RawValue +from temporalio.contrib.workflow_streams import WorkflowStreamClient + +from workflow_streams.chat_shared import ( + CHAT_TASK_QUEUE, + TOPIC_COMPLETE, + TOPIC_DELTA, + TOPIC_RETRY, + ChatInput, + RetryEvent, + TextComplete, + TextDelta, +) +from workflow_streams.workflows.chat_workflow import ChatWorkflow + +# A prompt long enough that you can comfortably kill the worker +# mid-stream and watch the retry render. Adjust to taste. +DEFAULT_PROMPT = ( + "Write a 250-word friendly explainer for a new engineer about why " + "durable execution matters in distributed systems. Use short " + "paragraphs and a couple of concrete examples." +) + + +def _ansi_clear(line_count: int) -> None: + """Move the cursor up `line_count` lines and clear to end of screen. + + Used on RETRY to throw away the failed attempt's rendered output + before the retried attempt starts. Counts logical newlines in the + rendered text; a long line that wraps in the terminal won't be + fully cleared by this — accept the rough edges, ``rich`` would do + it cleanly but we are deliberately stdlib-only here. + """ + sys.stdout.write("\r") + if line_count > 0: + sys.stdout.write(f"\033[{line_count}A") + sys.stdout.write("\033[J") + sys.stdout.flush() + + +async def main() -> None: + client = await Client.connect("localhost:7233") + converter = client.data_converter.payload_converter + + workflow_id = f"workflow-stream-chat-{uuid.uuid4().hex[:8]}" + handle = await client.start_workflow( + ChatWorkflow.run, + ChatInput(prompt=DEFAULT_PROMPT), + id=workflow_id, + task_queue=CHAT_TASK_QUEUE, + ) + + stream = WorkflowStreamClient.create(client, workflow_id) + + # Subscribe to all three topics on a single iterator. result_type= + # RawValue lets us dispatch on item.topic and decode against the + # right dataclass per topic. + accumulated: list[str] = [] + async for item in stream.subscribe( + [TOPIC_DELTA, TOPIC_RETRY, TOPIC_COMPLETE], + result_type=RawValue, + ): + if item.topic == TOPIC_RETRY: + evt = converter.from_payload(item.data.payload, RetryEvent) + line_count = "".join(accumulated).count("\n") + _ansi_clear(line_count) + print(f"[retry attempt {evt.attempt}] resetting output\n") + accumulated.clear() + elif item.topic == TOPIC_DELTA: + delta = converter.from_payload(item.data.payload, TextDelta) + accumulated.append(delta.text) + sys.stdout.write(delta.text) + sys.stdout.flush() + elif item.topic == TOPIC_COMPLETE: + # Newline so the prompt isn't on the same line as the + # last delta. The TextComplete payload is the full text + # (also returned by the workflow), but the consumer has + # already rendered it incrementally so we don't reprint. + converter.from_payload(item.data.payload, TextComplete) + print() + break + + result = await handle.result() + print(f"\n[workflow result: {len(result)} chars]") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_streams/run_chat_worker.py b/workflow_streams/run_chat_worker.py new file mode 100644 index 00000000..8cb4f9ff --- /dev/null +++ b/workflow_streams/run_chat_worker.py @@ -0,0 +1,40 @@ +"""Worker for the LLM-streaming scenario. + +Runs separately from ``run_worker.py`` so the ``openai`` dependency +and the ``OPENAI_API_KEY`` requirement stay isolated to this one +scenario. Different task queue too — the other four samples won't +route work to this worker. + +Kill this worker mid-stream while ``run_chat.py`` is running to +trigger a retry: Temporal restarts the activity on the next worker +to come up, the activity publishes a ``RetryEvent`` on its second +attempt, and the consumer resets its rendered output. +""" + +from __future__ import annotations + +import asyncio +import logging + +from temporalio.client import Client +from temporalio.worker import Worker + +from workflow_streams.activities.chat_activity import stream_completion +from workflow_streams.chat_shared import CHAT_TASK_QUEUE +from workflow_streams.workflows.chat_workflow import ChatWorkflow + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + client = await Client.connect("localhost:7233") + worker = Worker( + client, + task_queue=CHAT_TASK_QUEUE, + workflows=[ChatWorkflow], + activities=[stream_completion], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/workflow_streams/workflows/chat_workflow.py b/workflow_streams/workflows/chat_workflow.py new file mode 100644 index 00000000..0737cf37 --- /dev/null +++ b/workflow_streams/workflows/chat_workflow.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.contrib.workflow_streams import WorkflowStream + +from workflow_streams.chat_shared import ChatInput + +with workflow.unsafe.imports_passed_through(): + from workflow_streams.activities.chat_activity import stream_completion + + +@workflow.defn +class ChatWorkflow: + """Wrapper for an LLM-streaming activity. + + The workflow does no streaming of its own; it hosts the + `WorkflowStream` so external subscribers can attach by workflow + id, kicks off the streaming activity, and returns the full text + the activity produced. + + Streaming is delegated to the activity because the OpenAI call is + non-deterministic. If the activity fails partway through, Temporal + retries it (up to ``max_attempts``); the retried attempt + re-publishes from the start, so the consumer must reset on the + activity's ``RETRY`` event. See + `activities/chat_activity.py` and `run_chat.py`. + """ + + @workflow.init + def __init__(self, input: ChatInput) -> None: + # Construct the stream from `@workflow.init` so the + # publish-Signal handler is registered before any external + # publisher (the activity, here) tries to publish. + self.stream = WorkflowStream(prior_state=input.stream_state) + + @workflow.run + async def run(self, input: ChatInput) -> str: + result = await workflow.execute_activity( + stream_completion, + input, + start_to_close_timeout=timedelta(minutes=5), + retry_policy=RetryPolicy(maximum_attempts=3), + ) + # Hold the run open briefly so the consumer's next poll + # delivers the activity's terminal `complete` event before the + # workflow exits and the in-memory log is gone. + await workflow.sleep(timedelta(milliseconds=500)) + return result From e8620c61f3836db832a310334088d03749333400 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 18:43:24 -0700 Subject: [PATCH 14/23] samples: workflow_streams: drop chat-stream openai upper cap openai-agents (the existing langsmith-tracing / openai-agents extras) already pulls openai>=2.26.0. Capping chat-stream at openai<2 made the two extras unsatisfiable together. Drop the cap; the chat activity uses APIs that are stable across openai 1.x and 2.x. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index db2e72c7..c8458110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ openai-agents = [ pydantic-converter = ["pydantic>=2.10.6,<3"] sentry = ["sentry-sdk>=2.13.0"] trio-async = ["trio>=0.28.0,<0.29", "trio-asyncio>=0.15.0,<0.16"] -chat-stream = ["openai>=1.0,<2"] +chat-stream = ["openai>=1.0"] cloud-export-to-parquet = [ "pandas>=2.2.2,<3 ; python_version >= '3.10' and python_version < '4.0'", "numpy>=1.26.0,<2 ; python_version >= '3.10' and python_version < '3.13'", From 0b4cbc8e8813b09fd148a534bdf48bcf6c717ff7 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 18:52:23 -0700 Subject: [PATCH 15/23] samples: workflow_streams: chat consumer header + cursor save/restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two display fixes for run_chat.py: 1. Print a header line right after start_workflow so the user sees immediate feedback ("[chat ] streaming response from gpt-5-mini, awaiting first token...") instead of a blank screen until the first delta arrives. 2. Replace the newline-counting ANSI clear with cursor save/restore (\033[s / \033[u\033[J). The previous version counted text newlines to decide how far up to move the cursor on retry, which undercounts when the terminal has wrapped long lines — the failed attempt's first wrapped lines stayed on screen above the retry marker. save/restore rewinds to a fixed position regardless of wrapping. Bumps the prompt to a 500-word distributed-systems comparison (Paxos vs Raft vs Viewstamped Replication) so there is enough output to comfortably kill the worker mid-stream and watch the retried attempt re-render from scratch. --- workflow_streams/run_chat.py | 73 ++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/workflow_streams/run_chat.py b/workflow_streams/run_chat.py index 7a4d391c..af99e79e 100644 --- a/workflow_streams/run_chat.py +++ b/workflow_streams/run_chat.py @@ -46,29 +46,25 @@ ) from workflow_streams.workflows.chat_workflow import ChatWorkflow -# A prompt long enough that you can comfortably kill the worker -# mid-stream and watch the retry render. Adjust to taste. +# Long enough that you can comfortably kill the worker mid-stream and +# watch the retry render. Adjust to taste. DEFAULT_PROMPT = ( - "Write a 250-word friendly explainer for a new engineer about why " - "durable execution matters in distributed systems. Use short " - "paragraphs and a couple of concrete examples." + "Write a 500-word comparison of Paxos, Raft, and Viewstamped " + "Replication for a new distributed-systems engineer. Cover the " + "core ideas, leader election, normal-case operation, " + "reconfiguration, and the practical tradeoffs that show up when " + "implementing each. Use short paragraphs." ) -def _ansi_clear(line_count: int) -> None: - """Move the cursor up `line_count` lines and clear to end of screen. - - Used on RETRY to throw away the failed attempt's rendered output - before the retried attempt starts. Counts logical newlines in the - rendered text; a long line that wraps in the terminal won't be - fully cleared by this — accept the rough edges, ``rich`` would do - it cleanly but we are deliberately stdlib-only here. - """ - sys.stdout.write("\r") - if line_count > 0: - sys.stdout.write(f"\033[{line_count}A") - sys.stdout.write("\033[J") - sys.stdout.flush() +# ANSI cursor save / restore. ``\033[s`` saves the current cursor +# position, ``\033[u`` restores it, ``\033[J`` clears from the cursor +# to the end of the screen. Save once before the first delta, and on +# RETRY restore + clear-to-end so the failed attempt's rendered output +# disappears regardless of how it was wrapped by the terminal. Save +# again afterwards so a second retry can rewind to the same point. +ANSI_SAVE = "\033[s" +ANSI_RESTORE_AND_CLEAR = "\033[u\033[J" async def main() -> None: @@ -76,39 +72,50 @@ async def main() -> None: converter = client.data_converter.payload_converter workflow_id = f"workflow-stream-chat-{uuid.uuid4().hex[:8]}" + chat_input = ChatInput(prompt=DEFAULT_PROMPT) handle = await client.start_workflow( ChatWorkflow.run, - ChatInput(prompt=DEFAULT_PROMPT), + chat_input, id=workflow_id, task_queue=CHAT_TASK_QUEUE, ) + # Print a header so the user sees something immediately. The + # response will start streaming below it once the first delta + # arrives — until then this is the only line on screen. + print( + f"[chat {workflow_id}] streaming response from {chat_input.model}, " + f"awaiting first token..." + ) + print() + sys.stdout.write(ANSI_SAVE) + sys.stdout.flush() + stream = WorkflowStreamClient.create(client, workflow_id) - # Subscribe to all three topics on a single iterator. result_type= - # RawValue lets us dispatch on item.topic and decode against the - # right dataclass per topic. - accumulated: list[str] = [] + # Subscribe to all three topics on a single iterator. + # result_type=RawValue lets us dispatch on item.topic and decode + # against the right dataclass per topic. async for item in stream.subscribe( [TOPIC_DELTA, TOPIC_RETRY, TOPIC_COMPLETE], result_type=RawValue, ): if item.topic == TOPIC_RETRY: evt = converter.from_payload(item.data.payload, RetryEvent) - line_count = "".join(accumulated).count("\n") - _ansi_clear(line_count) - print(f"[retry attempt {evt.attempt}] resetting output\n") - accumulated.clear() + sys.stdout.write(ANSI_RESTORE_AND_CLEAR) + sys.stdout.flush() + print(f"[retry attempt {evt.attempt}] resetting output") + print() + sys.stdout.write(ANSI_SAVE) + sys.stdout.flush() elif item.topic == TOPIC_DELTA: delta = converter.from_payload(item.data.payload, TextDelta) - accumulated.append(delta.text) sys.stdout.write(delta.text) sys.stdout.flush() elif item.topic == TOPIC_COMPLETE: - # Newline so the prompt isn't on the same line as the - # last delta. The TextComplete payload is the full text - # (also returned by the workflow), but the consumer has - # already rendered it incrementally so we don't reprint. + # The full text is also in the payload (and returned by + # the workflow), but the consumer has already rendered it + # incrementally. Just terminate the line. converter.from_payload(item.data.payload, TextComplete) print() break From 81bf6051124c4032548d9e64e7e4dec0d327c6d2 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 18:56:34 -0700 Subject: [PATCH 16/23] samples: workflow_streams: rename chat -> llm in scenario 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Chat" implies multi-turn conversation. The new scenario is a one-shot LLM completion stream, not a chat. Rename to make the scope clear: - chat_shared.py -> llm_shared.py - workflows/chat_workflow.py -> workflows/llm_workflow.py - activities/chat_activity.py -> activities/llm_activity.py - run_chat.py -> run_llm.py - run_chat_worker.py -> run_llm_worker.py - ChatInput / ChatWorkflow -> LLMInput / LLMWorkflow - CHAT_TASK_QUEUE -> LLM_TASK_QUEUE ("workflow-stream-chat-task-queue" -> "workflow-stream-llm-task-queue") - chat-stream extra -> llm-stream - workflow id prefix workflow-stream-chat-... -> workflow-stream-llm-... The activity's `stream_completion` defn name and the topic constants (`delta`, `complete`, `retry`) stay the same — those already describe what they do without the "chat" framing. README, docstrings, and run instructions updated to match. --- pyproject.toml | 2 +- workflow_streams/README.md | 22 ++++++------- .../{chat_activity.py => llm_activity.py} | 8 ++--- .../{chat_shared.py => llm_shared.py} | 12 +++---- workflow_streams/{run_chat.py => run_llm.py} | 32 +++++++++---------- .../{run_chat_worker.py => run_llm_worker.py} | 12 +++---- .../{chat_workflow.py => llm_workflow.py} | 12 +++---- 7 files changed, 50 insertions(+), 50 deletions(-) rename workflow_streams/activities/{chat_activity.py => llm_activity.py} (93%) rename workflow_streams/{chat_shared.py => llm_shared.py} (68%) rename workflow_streams/{run_chat.py => run_llm.py} (83%) rename workflow_streams/{run_chat_worker.py => run_llm_worker.py} (72%) rename workflow_streams/workflows/{chat_workflow.py => llm_workflow.py} (84%) diff --git a/pyproject.toml b/pyproject.toml index c8458110..9c9bace1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ openai-agents = [ pydantic-converter = ["pydantic>=2.10.6,<3"] sentry = ["sentry-sdk>=2.13.0"] trio-async = ["trio>=0.28.0,<0.29", "trio-asyncio>=0.15.0,<0.16"] -chat-stream = ["openai>=1.0"] +llm-stream = ["openai>=1.0"] cloud-export-to-parquet = [ "pandas>=2.2.2,<3 ; python_version >= '3.10' and python_version < '4.0'", "numpy>=1.26.0,<2 ; python_version >= '3.10' and python_version < '3.13'", diff --git a/workflow_streams/README.md b/workflow_streams/README.md index aa8e84d5..60004959 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -71,49 +71,49 @@ it's separate). ## Scenario 5 — LLM streaming -* `workflows/chat_workflow.py` — a workflow that hosts a +* `workflows/llm_workflow.py` — a workflow that hosts a `WorkflowStream` and runs `stream_completion` as a single activity. The workflow itself does no streaming; the activity owns the non-deterministic OpenAI call. -* `activities/chat_activity.py` — calls +* `activities/llm_activity.py` — calls `openai.AsyncOpenAI().chat.completions.create(stream=True)`, publishes each token chunk as a `TextDelta` on the `delta` topic, the final accumulated text on the `complete` topic, and a `RetryEvent` on the `retry` topic when running on attempt > 1. -* `run_chat.py` — subscribes to all three topics, renders deltas to +* `run_llm.py` — subscribes to all three topics, renders deltas to the terminal as they arrive, and on a `retry` event uses ANSI escapes to rewind the printed output before the retried attempt starts re-publishing. -* `run_chat_worker.py` — separate worker on its own task queue - (`workflow-stream-chat-task-queue`), registering only `ChatWorkflow` +* `run_llm_worker.py` — separate worker on its own task queue + (`workflow-stream-llm-task-queue`), registering only `LLMWorkflow` and `stream_completion`. This isolates the `openai` dependency and the `OPENAI_API_KEY` requirement to this one scenario. This scenario is split out for two reasons. First, it needs an extra dependency (`openai`) and a secret (`OPENAI_API_KEY`) — putting it on the main worker would force every other scenario to set up an OpenAI -key. Second, killing the chat worker mid-stream is the easiest way to +key. Second, killing the LLM worker mid-stream is the easiest way to demonstrate retry handling, and you don't want the same `Ctrl-C` to interrupt the other four scenarios' worker. Setup: ```bash -uv sync --group chat-stream +uv sync --group llm-stream export OPENAI_API_KEY=... ``` Run: ```bash -# Terminal 1: chat worker (its own task queue) -uv run workflow_streams/run_chat_worker.py +# Terminal 1: LLM worker (its own task queue) +uv run workflow_streams/run_llm_worker.py # Terminal 2: -uv run workflow_streams/run_chat.py +uv run workflow_streams/run_llm.py ``` -To trigger the retry path, kill the chat worker in Terminal 1 +To trigger the retry path, kill the LLM worker in Terminal 1 (`Ctrl-C`) while output is streaming, then start it again. The activity's next attempt sends a `RetryEvent` first; the consumer clears its on-screen output via ANSI escapes and re-renders from diff --git a/workflow_streams/activities/chat_activity.py b/workflow_streams/activities/llm_activity.py similarity index 93% rename from workflow_streams/activities/chat_activity.py rename to workflow_streams/activities/llm_activity.py index 0443d0cc..8b13b48a 100644 --- a/workflow_streams/activities/chat_activity.py +++ b/workflow_streams/activities/llm_activity.py @@ -6,11 +6,11 @@ from temporalio import activity from temporalio.contrib.workflow_streams import WorkflowStreamClient -from workflow_streams.chat_shared import ( +from workflow_streams.llm_shared import ( TOPIC_COMPLETE, TOPIC_DELTA, TOPIC_RETRY, - ChatInput, + LLMInput, RetryEvent, TextComplete, TextDelta, @@ -18,8 +18,8 @@ @activity.defn -async def stream_completion(input: ChatInput) -> str: - """Stream a chat completion to the parent workflow's stream. +async def stream_completion(input: LLMInput) -> str: + """Stream an LLM completion to the parent workflow's stream. Activity-as-publisher: each delta from the OpenAI streaming API is pushed to the workflow's stream as a ``TextDelta`` event on the diff --git a/workflow_streams/chat_shared.py b/workflow_streams/llm_shared.py similarity index 68% rename from workflow_streams/chat_shared.py rename to workflow_streams/llm_shared.py index 88e0de80..2780fd0b 100644 --- a/workflow_streams/chat_shared.py +++ b/workflow_streams/llm_shared.py @@ -1,8 +1,8 @@ """Types and constants for the LLM-streaming scenario. Kept separate from ``shared.py`` because the other scenarios don't -use these — and the chat scenario runs on its own worker and task -queue so the ``openai`` dependency stays out of everyone else's path. +use these — and this scenario runs on its own worker and task queue +so the ``openai`` dependency stays out of everyone else's path. """ from __future__ import annotations @@ -11,9 +11,9 @@ from temporalio.contrib.workflow_streams import WorkflowStreamState -# Scenario 5 (LLM streaming) runs on its own worker so the openai -# dependency only matters for that scenario. -CHAT_TASK_QUEUE = "workflow-stream-chat-task-queue" +# Scenario 5 runs on its own worker so the openai dependency only +# matters for that scenario. +LLM_TASK_QUEUE = "workflow-stream-llm-task-queue" # Topics published by the activity. TOPIC_DELTA = "delta" @@ -22,7 +22,7 @@ @dataclass -class ChatInput: +class LLMInput: prompt: str model: str = "gpt-5-mini" # Carries stream state across continue-as-new. None on a fresh start. diff --git a/workflow_streams/run_chat.py b/workflow_streams/run_llm.py similarity index 83% rename from workflow_streams/run_chat.py rename to workflow_streams/run_llm.py index af99e79e..3acb5315 100644 --- a/workflow_streams/run_chat.py +++ b/workflow_streams/run_llm.py @@ -1,24 +1,24 @@ """Stream LLM output to the terminal, handling retries. -Starts a ``ChatWorkflow``, subscribes to its delta / complete / retry +Starts an ``LLMWorkflow``, subscribes to its delta / complete / retry topics, and renders the model's output to stdout as it arrives. On a ``RETRY`` event (the activity is on attempt > 1), the consumer rewinds its rendered output with ANSI escapes and starts fresh — so a killed worker doesn't leave a half-finished response stuck on screen followed by the retried attempt's full output. -Requires ``OPENAI_API_KEY`` in the environment and the ``chat-stream`` +Requires ``OPENAI_API_KEY`` in the environment and the ``llm-stream`` extra:: - uv sync --group chat-stream + uv sync --group llm-stream export OPENAI_API_KEY=... -Run the chat worker first (``uv run workflow_streams/run_chat_worker.py``), +Run the LLM worker first (``uv run workflow_streams/run_llm_worker.py``), then:: - uv run workflow_streams/run_chat.py + uv run workflow_streams/run_llm.py -To see retry handling in action, kill the chat worker mid-stream +To see retry handling in action, kill the LLM worker mid-stream (Ctrl-C in its terminal) and start it again. The consumer will clear its accumulated output on the ``RETRY`` event and re-render the retried attempt's output from scratch. @@ -34,17 +34,17 @@ from temporalio.common import RawValue from temporalio.contrib.workflow_streams import WorkflowStreamClient -from workflow_streams.chat_shared import ( - CHAT_TASK_QUEUE, +from workflow_streams.llm_shared import ( + LLM_TASK_QUEUE, TOPIC_COMPLETE, TOPIC_DELTA, TOPIC_RETRY, - ChatInput, + LLMInput, RetryEvent, TextComplete, TextDelta, ) -from workflow_streams.workflows.chat_workflow import ChatWorkflow +from workflow_streams.workflows.llm_workflow import LLMWorkflow # Long enough that you can comfortably kill the worker mid-stream and # watch the retry render. Adjust to taste. @@ -71,20 +71,20 @@ async def main() -> None: client = await Client.connect("localhost:7233") converter = client.data_converter.payload_converter - workflow_id = f"workflow-stream-chat-{uuid.uuid4().hex[:8]}" - chat_input = ChatInput(prompt=DEFAULT_PROMPT) + workflow_id = f"workflow-stream-llm-{uuid.uuid4().hex[:8]}" + llm_input = LLMInput(prompt=DEFAULT_PROMPT) handle = await client.start_workflow( - ChatWorkflow.run, - chat_input, + LLMWorkflow.run, + llm_input, id=workflow_id, - task_queue=CHAT_TASK_QUEUE, + task_queue=LLM_TASK_QUEUE, ) # Print a header so the user sees something immediately. The # response will start streaming below it once the first delta # arrives — until then this is the only line on screen. print( - f"[chat {workflow_id}] streaming response from {chat_input.model}, " + f"[llm {workflow_id}] streaming response from {llm_input.model}, " f"awaiting first token..." ) print() diff --git a/workflow_streams/run_chat_worker.py b/workflow_streams/run_llm_worker.py similarity index 72% rename from workflow_streams/run_chat_worker.py rename to workflow_streams/run_llm_worker.py index 8cb4f9ff..2bad5991 100644 --- a/workflow_streams/run_chat_worker.py +++ b/workflow_streams/run_llm_worker.py @@ -5,7 +5,7 @@ scenario. Different task queue too — the other four samples won't route work to this worker. -Kill this worker mid-stream while ``run_chat.py`` is running to +Kill this worker mid-stream while ``run_llm.py`` is running to trigger a retry: Temporal restarts the activity on the next worker to come up, the activity publishes a ``RetryEvent`` on its second attempt, and the consumer resets its rendered output. @@ -19,9 +19,9 @@ from temporalio.client import Client from temporalio.worker import Worker -from workflow_streams.activities.chat_activity import stream_completion -from workflow_streams.chat_shared import CHAT_TASK_QUEUE -from workflow_streams.workflows.chat_workflow import ChatWorkflow +from workflow_streams.activities.llm_activity import stream_completion +from workflow_streams.llm_shared import LLM_TASK_QUEUE +from workflow_streams.workflows.llm_workflow import LLMWorkflow async def main() -> None: @@ -29,8 +29,8 @@ async def main() -> None: client = await Client.connect("localhost:7233") worker = Worker( client, - task_queue=CHAT_TASK_QUEUE, - workflows=[ChatWorkflow], + task_queue=LLM_TASK_QUEUE, + workflows=[LLMWorkflow], activities=[stream_completion], ) await worker.run() diff --git a/workflow_streams/workflows/chat_workflow.py b/workflow_streams/workflows/llm_workflow.py similarity index 84% rename from workflow_streams/workflows/chat_workflow.py rename to workflow_streams/workflows/llm_workflow.py index 0737cf37..b26cfbe4 100644 --- a/workflow_streams/workflows/chat_workflow.py +++ b/workflow_streams/workflows/llm_workflow.py @@ -6,14 +6,14 @@ from temporalio.common import RetryPolicy from temporalio.contrib.workflow_streams import WorkflowStream -from workflow_streams.chat_shared import ChatInput +from workflow_streams.llm_shared import LLMInput with workflow.unsafe.imports_passed_through(): - from workflow_streams.activities.chat_activity import stream_completion + from workflow_streams.activities.llm_activity import stream_completion @workflow.defn -class ChatWorkflow: +class LLMWorkflow: """Wrapper for an LLM-streaming activity. The workflow does no streaming of its own; it hosts the @@ -26,18 +26,18 @@ class ChatWorkflow: retries it (up to ``max_attempts``); the retried attempt re-publishes from the start, so the consumer must reset on the activity's ``RETRY`` event. See - `activities/chat_activity.py` and `run_chat.py`. + `activities/llm_activity.py` and `run_llm.py`. """ @workflow.init - def __init__(self, input: ChatInput) -> None: + def __init__(self, input: LLMInput) -> None: # Construct the stream from `@workflow.init` so the # publish-Signal handler is registered before any external # publisher (the activity, here) tries to publish. self.stream = WorkflowStream(prior_state=input.stream_state) @workflow.run - async def run(self, input: ChatInput) -> str: + async def run(self, input: LLMInput) -> str: result = await workflow.execute_activity( stream_completion, input, From c8663e54f524fca84a5b2e45802dd5bed4d3fd6f Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:04:33 -0700 Subject: [PATCH 17/23] samples: workflow_streams: race the LLM consumer with workflow result If the LLM activity exhausts its retries (bad OPENAI_API_KEY, provider outage, etc.), the workflow fails before the activity publishes the `complete` terminator. The consumer's previous async-for loop only exited on `complete`, so the script blocked indefinitely on a terminator that would never arrive instead of surfacing the workflow failure. Wrap the subscriber in a `consume()` coroutine and run it through the existing `race_with_workflow` helper (the same pattern `run_publisher.py` uses): if the workflow finishes first the subscriber gets cancelled and the workflow's exception propagates; if the subscriber sees `complete` first, the helper waits for the workflow result and returns it. Found in a Codex code review of today's workflow_streams changes. --- workflow_streams/run_llm.py | 62 ++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/workflow_streams/run_llm.py b/workflow_streams/run_llm.py index 3acb5315..dd48ecf0 100644 --- a/workflow_streams/run_llm.py +++ b/workflow_streams/run_llm.py @@ -44,6 +44,7 @@ TextComplete, TextDelta, ) +from workflow_streams.shared import race_with_workflow from workflow_streams.workflows.llm_workflow import LLMWorkflow # Long enough that you can comfortably kill the worker mid-stream and @@ -93,34 +94,39 @@ async def main() -> None: stream = WorkflowStreamClient.create(client, workflow_id) - # Subscribe to all three topics on a single iterator. - # result_type=RawValue lets us dispatch on item.topic and decode - # against the right dataclass per topic. - async for item in stream.subscribe( - [TOPIC_DELTA, TOPIC_RETRY, TOPIC_COMPLETE], - result_type=RawValue, - ): - if item.topic == TOPIC_RETRY: - evt = converter.from_payload(item.data.payload, RetryEvent) - sys.stdout.write(ANSI_RESTORE_AND_CLEAR) - sys.stdout.flush() - print(f"[retry attempt {evt.attempt}] resetting output") - print() - sys.stdout.write(ANSI_SAVE) - sys.stdout.flush() - elif item.topic == TOPIC_DELTA: - delta = converter.from_payload(item.data.payload, TextDelta) - sys.stdout.write(delta.text) - sys.stdout.flush() - elif item.topic == TOPIC_COMPLETE: - # The full text is also in the payload (and returned by - # the workflow), but the consumer has already rendered it - # incrementally. Just terminate the line. - converter.from_payload(item.data.payload, TextComplete) - print() - break - - result = await handle.result() + async def consume() -> None: + # Subscribe to all three topics on a single iterator. + # result_type=RawValue lets us dispatch on item.topic and + # decode against the right dataclass per topic. + async for item in stream.subscribe( + [TOPIC_DELTA, TOPIC_RETRY, TOPIC_COMPLETE], + result_type=RawValue, + ): + if item.topic == TOPIC_RETRY: + evt = converter.from_payload(item.data.payload, RetryEvent) + sys.stdout.write(ANSI_RESTORE_AND_CLEAR) + sys.stdout.flush() + print(f"[retry attempt {evt.attempt}] resetting output") + print() + sys.stdout.write(ANSI_SAVE) + sys.stdout.flush() + elif item.topic == TOPIC_DELTA: + delta = converter.from_payload(item.data.payload, TextDelta) + sys.stdout.write(delta.text) + sys.stdout.flush() + elif item.topic == TOPIC_COMPLETE: + # The full text is also in the payload (and returned + # by the workflow), but the consumer has already + # rendered it incrementally. Just terminate the line. + converter.from_payload(item.data.payload, TextComplete) + print() + return + + # Race the subscriber against the workflow result so that if the + # activity exhausts its retries the workflow's failure surfaces + # here rather than leaving the subscriber blocked on a `complete` + # that will never arrive. + result = await race_with_workflow(consume(), handle) print(f"\n[workflow result: {len(result)} chars]") From 44d944b5e8dced536b5e15d0a059d0d0271f21f7 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:09:14 -0700 Subject: [PATCH 18/23] samples: workflow_streams: drop race_with_workflow helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper wrapped the consumer in an asyncio.gather that cancelled the subscriber when the workflow result settled — defensive logic for a case the SDK already handles. WorkflowStreamClient.subscribe() exits cleanly on every workflow terminal state (return, continue-as-new, failure) via its AcceptedUpdateCompletedWorkflow, WorkflowUpdateRPCTimeoutOrCancelledError, and NOT_FOUND branches in sdk-python. The async-for loop ends naturally when the workflow terminates without a publish, so we don't need a separate task to race against handle.result(). Replace the helper with the obvious shape in both runners: async for item in stream.subscribe(...): ... if item.is_terminator: break result = await handle.result() # raises on workflow failure Either path reaches handle.result(): an explicit break on the in-band terminator (workflow still running, hold-open lets the poll deliver the event), or the iterator naturally exhausting when the workflow has already terminated. handle.result() then either returns or raises the workflow's failure — covering the LLM "activity exhausted retries" case that prompted the helper to be added in the first place. Smoke tested: uv run workflow_streams/run_publisher.py uv run workflow_streams/run_llm.py --- workflow_streams/run_llm.py | 66 +++++++++++++++---------------- workflow_streams/run_publisher.py | 40 ++++++++++--------- workflow_streams/shared.py | 58 --------------------------- 3 files changed, 53 insertions(+), 111 deletions(-) diff --git a/workflow_streams/run_llm.py b/workflow_streams/run_llm.py index dd48ecf0..1b2a9d7a 100644 --- a/workflow_streams/run_llm.py +++ b/workflow_streams/run_llm.py @@ -44,7 +44,6 @@ TextComplete, TextDelta, ) -from workflow_streams.shared import race_with_workflow from workflow_streams.workflows.llm_workflow import LLMWorkflow # Long enough that you can comfortably kill the worker mid-stream and @@ -94,39 +93,38 @@ async def main() -> None: stream = WorkflowStreamClient.create(client, workflow_id) - async def consume() -> None: - # Subscribe to all three topics on a single iterator. - # result_type=RawValue lets us dispatch on item.topic and - # decode against the right dataclass per topic. - async for item in stream.subscribe( - [TOPIC_DELTA, TOPIC_RETRY, TOPIC_COMPLETE], - result_type=RawValue, - ): - if item.topic == TOPIC_RETRY: - evt = converter.from_payload(item.data.payload, RetryEvent) - sys.stdout.write(ANSI_RESTORE_AND_CLEAR) - sys.stdout.flush() - print(f"[retry attempt {evt.attempt}] resetting output") - print() - sys.stdout.write(ANSI_SAVE) - sys.stdout.flush() - elif item.topic == TOPIC_DELTA: - delta = converter.from_payload(item.data.payload, TextDelta) - sys.stdout.write(delta.text) - sys.stdout.flush() - elif item.topic == TOPIC_COMPLETE: - # The full text is also in the payload (and returned - # by the workflow), but the consumer has already - # rendered it incrementally. Just terminate the line. - converter.from_payload(item.data.payload, TextComplete) - print() - return - - # Race the subscriber against the workflow result so that if the - # activity exhausts its retries the workflow's failure surfaces - # here rather than leaving the subscriber blocked on a `complete` - # that will never arrive. - result = await race_with_workflow(consume(), handle) + # result_type=RawValue lets us dispatch on item.topic and decode + # against the right dataclass per topic. The loop ends either on + # the `complete` terminator (break) or because the iterator + # naturally exhausts when the workflow reaches a terminal state + # without one (activity exhausted retries, etc.). Either way the + # handle.result() below either returns the full text or raises + # the workflow's failure. + async for item in stream.subscribe( + [TOPIC_DELTA, TOPIC_RETRY, TOPIC_COMPLETE], + result_type=RawValue, + ): + if item.topic == TOPIC_RETRY: + evt = converter.from_payload(item.data.payload, RetryEvent) + sys.stdout.write(ANSI_RESTORE_AND_CLEAR) + sys.stdout.flush() + print(f"[retry attempt {evt.attempt}] resetting output") + print() + sys.stdout.write(ANSI_SAVE) + sys.stdout.flush() + elif item.topic == TOPIC_DELTA: + delta = converter.from_payload(item.data.payload, TextDelta) + sys.stdout.write(delta.text) + sys.stdout.flush() + elif item.topic == TOPIC_COMPLETE: + # The full text is also in the payload (and returned by + # the workflow), but the consumer has already rendered it + # incrementally. Just terminate the line. + converter.from_payload(item.data.payload, TextComplete) + print() + break + + result = await handle.result() print(f"\n[workflow result: {len(result)} chars]") diff --git a/workflow_streams/run_publisher.py b/workflow_streams/run_publisher.py index 2e5ddb8d..9f76ee41 100644 --- a/workflow_streams/run_publisher.py +++ b/workflow_streams/run_publisher.py @@ -14,7 +14,6 @@ OrderInput, ProgressEvent, StatusEvent, - race_with_workflow, ) from workflow_streams.workflows.order_workflow import OrderWorkflow @@ -33,24 +32,27 @@ async def main() -> None: stream = WorkflowStreamClient.create(client, workflow_id) converter = client.data_converter.payload_converter - async def consume() -> None: - # Single iterator over both topics — avoids a cancellation race - # between two concurrent subscribers. result_type=RawValue - # delivers the underlying Payload so we can dispatch - # heterogeneous events on item.topic. - async for item in stream.subscribe( - [TOPIC_STATUS, TOPIC_PROGRESS], result_type=RawValue - ): - if item.topic == TOPIC_STATUS: - evt = converter.from_payload(item.data.payload, StatusEvent) - print(f"[status] {evt.kind}: order={evt.order_id}") - if evt.kind == "complete": - return - elif item.topic == TOPIC_PROGRESS: - progress = converter.from_payload(item.data.payload, ProgressEvent) - print(f"[progress] {progress.message}") - - result = await race_with_workflow(consume(), handle) + # Single iterator over both topics — avoids a cancellation race + # between two concurrent subscribers. result_type=RawValue + # delivers the underlying Payload so we can dispatch heterogeneous + # events on item.topic. The loop ends either on the in-band + # `complete` terminator (break) or because the iterator exhausts + # when the workflow reaches a terminal state without one (e.g. on + # failure). Either way we then await handle.result(), which raises + # if the workflow failed. + async for item in stream.subscribe( + [TOPIC_STATUS, TOPIC_PROGRESS], result_type=RawValue + ): + if item.topic == TOPIC_STATUS: + evt = converter.from_payload(item.data.payload, StatusEvent) + print(f"[status] {evt.kind}: order={evt.order_id}") + if evt.kind == "complete": + break + elif item.topic == TOPIC_PROGRESS: + progress = converter.from_payload(item.data.payload, ProgressEvent) + print(f"[progress] {progress.message}") + + result = await handle.result() print(f"workflow result: {result}") diff --git a/workflow_streams/shared.py b/workflow_streams/shared.py index 746ee73d..9bf5a4b7 100644 --- a/workflow_streams/shared.py +++ b/workflow_streams/shared.py @@ -1,11 +1,7 @@ from __future__ import annotations -import asyncio -from collections.abc import Coroutine from dataclasses import dataclass -from typing import Any, TypeVar -from temporalio.client import WorkflowHandle from temporalio.contrib.workflow_streams import WorkflowStreamState TASK_QUEUE = "workflow-stream-sample-task-queue" @@ -72,57 +68,3 @@ class TickerInput: @dataclass class TickEvent: n: int - - -T = TypeVar("T") - - -async def race_with_workflow( - consumer: Coroutine[Any, Any, None], - handle: WorkflowHandle[Any, T], -) -> T: - """Run a subscriber concurrently with the workflow. - - If the workflow finishes before the subscriber sees its terminal - event, cancel the subscriber and surface the workflow's result - (raising on failure). If the subscriber finishes first, wait for - the workflow result. A non-cancellation failure in the subscriber - is propagated either way. - - Without this, a workflow that raises before publishing its terminal - event would leave the subscriber blocked on its next poll forever. - """ - consumer_task = asyncio.create_task(consumer) - result_task = asyncio.create_task(handle.result()) - we_cancelled_consumer = False - try: - await asyncio.wait( - [consumer_task, result_task], - return_when=asyncio.FIRST_COMPLETED, - ) - if not consumer_task.done(): - consumer_task.cancel() - we_cancelled_consumer = True - # gather(return_exceptions=True) drains both tasks. Only - # cancellation we initiated is expected — anything else - # propagates. - consumer_outcome, workflow_outcome = await asyncio.gather( - consumer_task, result_task, return_exceptions=True - ) - if isinstance(consumer_outcome, asyncio.CancelledError): - if not we_cancelled_consumer: - raise consumer_outcome - elif isinstance(consumer_outcome, BaseException): - raise consumer_outcome - if isinstance(workflow_outcome, BaseException): - raise workflow_outcome - return workflow_outcome - finally: - for task in (consumer_task, result_task): - if not task.done(): - task.cancel() - for task in (consumer_task, result_task): - try: - await task - except BaseException: - pass From a760ad36c98b13197535f6cd476a209132e87750 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:21:25 -0700 Subject: [PATCH 19/23] samples: workflow_streams: reorganize README; drop closing section Two fixes: 1. Reorganize so the README doesn't jump back and forth between scenarios. The previous shape introduced 1-4, then put scenario 5's full description plus its setup and run instructions inline, then jumped back to a "Run it" section that only covered 1-4. New shape: all five scenarios up front (parallel structure), one unified "Run it" section that covers worker setup for both groups and all five runner scripts in one block, then expected output, then notes. 2. Drop the inline "Ending the stream" section. The same material is in documentation/docs/develop/python/libraries/workflow-streams.mdx under the "Closing the stream" anchor, so the README links there from the Notes block instead of duplicating the explanation. The scenario 5 "split-out worker" rationale (extra dependency, secret, retry-via-Ctrl-C) collapses to a single sentence at the end of its bullet block. --- workflow_streams/README.md | 201 ++++++++++++++----------------------- 1 file changed, 78 insertions(+), 123 deletions(-) diff --git a/workflow_streams/README.md b/workflow_streams/README.md index 60004959..f0bcb092 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -9,13 +9,12 @@ offset-addressed event channel. The workflow holds an append-only log; external clients (activities, starters, BFFs) publish to topics via signals and subscribe via long-poll updates. This packages the -boilerplate — batching, offset tracking, topic filtering, continue-as-new -hand-off — into a reusable stream. +boilerplate — batching, offset tracking, topic filtering, +continue-as-new hand-off — into a reusable stream. -This directory has four scenarios sharing one Worker, plus a fifth -LLM-streaming scenario on its own Worker (see -[Scenario 5 — LLM streaming](#scenario-5--llm-streaming) below for why -it's separate). +This directory has five scenarios. The first four share one worker; +the fifth has its own worker because it needs the `openai` package +and an `OPENAI_API_KEY`. **Scenario 1 — basic publish/subscribe with heterogeneous topics:** @@ -33,11 +32,11 @@ it's separate). publishes stage transitions over ~10 seconds, leaving room for a consumer to disconnect and reconnect mid-run. * `run_reconnecting_subscriber.py` — connects, reads a couple of - events, persists `item.offset + 1` to disk, "disconnects," then - reopens a fresh client and resumes via `subscribe(from_offset=...)`. - This is the central Workflow Streams use case: a consumer can - disappear (page refresh, server restart, laptop closed) and resume - later without missing events or seeing duplicates. + events, "disconnects," then reopens a fresh client and resumes via + `subscribe(from_offset=...)`. This is the central Workflow Streams + use case: a consumer can disappear (page refresh, server restart, + laptop closed) and resume later without missing events or seeing + duplicates. **Scenario 3 — external (non-Activity) publisher:** @@ -47,131 +46,82 @@ it's separate). * `run_external_publisher.py` — starts the hub, then publishes events into it from a plain Python coroutine using `WorkflowStreamClient.create(client, workflow_id)`. A subscriber - task runs alongside; when the publisher is done it emits an in-band - sentinel headline (`__done__`) into the stream, then signals - `HubWorkflow.close`. The subscriber breaks on the sentinel and - exits its `async for`. This is the shape that fits a backend - service or scheduled job pushing events into a workflow it didn't - itself start. + task runs alongside; when the publisher is done it emits a sentinel + event and signals `HubWorkflow.close`. The shape that fits a + backend service or scheduled job pushing events into a workflow it + didn't itself start. **Scenario 4 — bounded log via `truncate()`:** * `workflows/ticker_workflow.py` — a long-running workflow that publishes events at a fixed cadence and calls - `self.stream.truncate(...)` periodically to bound log growth, keeping - only the most recent N entries. + `self.stream.truncate(...)` periodically to bound log growth, + keeping only the most recent N entries. * `run_truncating_ticker.py` — runs a fast subscriber and a slow - subscriber side by side. The fast one keeps up and sees every offset - in order; the slow one sleeps between iterations, falls behind a - truncation, and silently jumps forward to the new base offset. The - output makes the trade visible: bounded log size in exchange for - intermediate events being invisible to slow consumers. - -`run_worker.py` registers all four workflows and the activity. - -## Scenario 5 — LLM streaming - -* `workflows/llm_workflow.py` — a workflow that hosts a - `WorkflowStream` and runs `stream_completion` as a single activity. - The workflow itself does no streaming; the activity owns the - non-deterministic OpenAI call. + subscriber side by side. The fast one keeps up and sees every + offset in order; the slow one falls behind a truncation and + silently jumps forward to the new base offset. The output makes + the trade visible: bounded log size in exchange for intermediate + events being invisible to slow consumers. + +**Scenario 5 — LLM streaming:** + +* `workflows/llm_workflow.py` — hosts a `WorkflowStream` and runs + `stream_completion` as a single activity. The workflow itself + does no streaming; the activity owns the non-deterministic OpenAI + call. * `activities/llm_activity.py` — calls `openai.AsyncOpenAI().chat.completions.create(stream=True)`, - publishes each token chunk as a `TextDelta` on the `delta` topic, - the final accumulated text on the `complete` topic, and a - `RetryEvent` on the `retry` topic when running on attempt > 1. + publishes each token chunk on the `delta` topic, the final + accumulated text on `complete`, and a `RetryEvent` on `retry` + when running on attempt > 1. * `run_llm.py` — subscribes to all three topics, renders deltas to the terminal as they arrive, and on a `retry` event uses ANSI escapes to rewind the printed output before the retried attempt - starts re-publishing. -* `run_llm_worker.py` — separate worker on its own task queue - (`workflow-stream-llm-task-queue`), registering only `LLMWorkflow` - and `stream_completion`. This isolates the `openai` dependency and - the `OPENAI_API_KEY` requirement to this one scenario. + re-publishes. -This scenario is split out for two reasons. First, it needs an extra -dependency (`openai`) and a secret (`OPENAI_API_KEY`) — putting it on -the main worker would force every other scenario to set up an OpenAI -key. Second, killing the LLM worker mid-stream is the easiest way to -demonstrate retry handling, and you don't want the same `Ctrl-C` to -interrupt the other four scenarios' worker. +Scenario 5 runs on its own worker (`run_llm_worker.py`, on +`workflow-stream-llm-task-queue`) because it needs the `openai` +dependency and an `OPENAI_API_KEY`, and because killing this worker +mid-stream is the easiest way to demonstrate retry handling without +disrupting the other four scenarios. + +## Run it -Setup: +For scenarios 1–4, start the shared worker: ```bash -uv sync --group llm-stream -export OPENAI_API_KEY=... +uv run workflow_streams/run_worker.py ``` -Run: +For scenario 5, install the extra, export the key, and start the +LLM worker: ```bash -# Terminal 1: LLM worker (its own task queue) +uv sync --group llm-stream +export OPENAI_API_KEY=... uv run workflow_streams/run_llm_worker.py +``` + +Then in another terminal, pick a scenario: -# Terminal 2: -uv run workflow_streams/run_llm.py +```bash +uv run workflow_streams/run_publisher.py # scenario 1 +uv run workflow_streams/run_reconnecting_subscriber.py # scenario 2 +uv run workflow_streams/run_external_publisher.py # scenario 3 +uv run workflow_streams/run_truncating_ticker.py # scenario 4 +uv run workflow_streams/run_llm.py # scenario 5 ``` -To trigger the retry path, kill the LLM worker in Terminal 1 -(`Ctrl-C`) while output is streaming, then start it again. The +To exercise scenario 5's retry path, kill `run_llm_worker.py` +(`Ctrl-C`) while output is streaming and start it again. The activity's next attempt sends a `RetryEvent` first; the consumer clears its on-screen output via ANSI escapes and re-renders from scratch. -## Ending the stream - -`WorkflowStreamClient.subscribe()` is a long-poll loop — it does not -exit on its own when the host workflow completes. Two things have to -happen at the end of a streamed workflow for clean shutdown: - -1. **An in-band terminator that subscribers recognize.** Each scenario - here sends one before the workflow exits: - - `OrderWorkflow` and `PipelineWorkflow` publish a "complete" - status / stage event; consumers break on it. - - `run_external_publisher.py` publishes a sentinel - `NewsEvent(headline="__done__")` immediately before signaling - `HubWorkflow.close`; the consumer breaks on the sentinel. - - `TickerWorkflow`'s final tick (`n == count - 1`) is the - terminator; subscribers break when they see it. `keep_last` - guarantees that final offset survives the last truncation, so - even slow consumers reach it. - -2. **A short hold-open in the workflow before returning** so that the - final publish gets fetched. Items published in the same workflow - task that returns from `@workflow.run` are abandoned: the - in-memory log dies with the workflow, and the next subscriber - poll lands on a completed workflow. Each workflow here ends with - - ```python - await workflow.sleep(timedelta(milliseconds=500)) - return ... - ``` - - which gives subscribers in their `poll_cooldown` interval time to - issue one more poll. With both pieces in place, subscribers - receive the terminator, break out of their `async for`, and stop - polling — by the time the workflow exits there are no in-flight - poll handlers, so the SDK does not warn about unfinished - handlers. - -## Run it - -```bash -# Terminal 1: worker -uv run workflow_streams/run_worker.py - -# Terminal 2: pick a scenario -uv run workflow_streams/run_publisher.py -# or -uv run workflow_streams/run_reconnecting_subscriber.py -# or -uv run workflow_streams/run_external_publisher.py -# or -uv run workflow_streams/run_truncating_ticker.py -``` +## Expected output -Expected output on the basic publisher side: +Scenario 1 (basic publisher): ``` [status] received: order=order-1 @@ -183,9 +133,9 @@ Expected output on the basic publisher side: workflow result: charge-order-1 ``` -Expected output on the reconnecting subscriber side. Each line carries -a stats column on the left (`proc`, `avail`, `pend`) and a phase / -event message on the right; a background poller emits a `·` heartbeat +Scenario 2 (reconnecting subscriber). Each line carries a stats +column on the left (`proc`, `avail`, `pend`) and a phase / event +message on the right; a background poller emits a `·` heartbeat once a second. Offsets are continuous across the disconnect — no events lost, none duplicated: @@ -208,17 +158,22 @@ proc= 6 avail= 6 pend= 0 │ workflow result: pipeline ... done ## Notes -* **Subscriber start position.** `subscribe(...)` without `from_offset` - starts at the stream's current base offset and follows live — older - events that have been truncated, or that arrived before the - subscribe call, are not replayed. Pass `from_offset=N` to resume - from a known position (see `run_reconnecting_subscriber.py`); the - iterator skips forward to the current base if `N` has been - truncated. +* **Subscriber start position.** `subscribe(...)` without + `from_offset` starts at the stream's current base offset and + follows live — older events that have been truncated, or that + arrived before the subscribe call, are not replayed. Pass + `from_offset=N` to resume from a known position (see + `run_reconnecting_subscriber.py`); the iterator skips forward to + the current base if `N` has been truncated. * **Continue-as-new.** Every `*Input` dataclass carries `stream_state: WorkflowStreamState | None = None`. To survive - continue-as-new without losing buffered items, capture the workflow's - stream state and pass it to the next run via - `WorkflowStream(prior_state=...)` in `@workflow.init`. The samples - declare the field for completeness; none of them actually trigger - continue-as-new. + continue-as-new without losing buffered items, capture the + workflow's stream state and pass it to the next run via + `WorkflowStream(prior_state=...)` in `@workflow.init`. The + samples declare the field for completeness; none of them + actually trigger continue-as-new. +* **Closing the stream.** Each scenario uses an in-band terminator + plus a short `workflow.sleep` hold-open so subscribers receive + the final event before the workflow exits. See + [Closing the stream](https://docs.temporal.io/develop/python/libraries/workflow-streams#closing-the-stream) + in the docs for the full pattern. From dc381c50292912430b4e61bc3ec39a9e35873007 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:25:11 -0700 Subject: [PATCH 20/23] samples: workflow_streams: drop README Notes section The Notes block (subscriber start position, continue-as-new, closing the stream) was a small docs summary tacked onto the end of the README. The samples themselves cover these points: docstrings in each runner / workflow / activity explain the from_offset behavior, the stream_state field, and the in-band terminator + hold-open pattern. Readers who want the full conceptual treatment go to the docs page; the README sticks to "what the scenarios are and how to run them". --- workflow_streams/README.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/workflow_streams/README.md b/workflow_streams/README.md index f0bcb092..b71e39d4 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -155,25 +155,3 @@ proc= 5 avail= 5 pend= 0 │ offset= 4 stage=verifying proc= 6 avail= 6 pend= 0 │ offset= 5 stage=complete proc= 6 avail= 6 pend= 0 │ workflow result: pipeline ... done ``` - -## Notes - -* **Subscriber start position.** `subscribe(...)` without - `from_offset` starts at the stream's current base offset and - follows live — older events that have been truncated, or that - arrived before the subscribe call, are not replayed. Pass - `from_offset=N` to resume from a known position (see - `run_reconnecting_subscriber.py`); the iterator skips forward to - the current base if `N` has been truncated. -* **Continue-as-new.** Every `*Input` dataclass carries - `stream_state: WorkflowStreamState | None = None`. To survive - continue-as-new without losing buffered items, capture the - workflow's stream state and pass it to the next run via - `WorkflowStream(prior_state=...)` in `@workflow.init`. The - samples declare the field for completeness; none of them - actually trigger continue-as-new. -* **Closing the stream.** Each scenario uses an in-band terminator - plus a short `workflow.sleep` hold-open so subscribers receive - the final event before the workflow exits. See - [Closing the stream](https://docs.temporal.io/develop/python/libraries/workflow-streams#closing-the-stream) - in the docs for the full pattern. From 7a5065eadb7aa4d7c157cebdf2e27ed4c28c5800 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:29:46 -0700 Subject: [PATCH 21/23] samples: workflow_streams: lock llm-stream dependency group The llm-stream dependency group was introduced in pyproject.toml without a corresponding uv.lock update, so `uv sync --frozen --group llm-stream` would fail or force a relock before scenario 5 could run. Add the two missing entries (the package-optional-dependencies list and the package-metadata requires-dev list) so frozen installs work against the committed lock. Found in a Codex review of the day's workflow_streams changes. --- uv.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/uv.lock b/uv.lock index 4dcdad5a..e025dab8 100644 --- a/uv.lock +++ b/uv.lock @@ -2589,6 +2589,9 @@ langsmith-tracing = [ { name = "openai" }, { name = "temporalio", extra = ["langsmith", "pydantic"] }, ] +llm-stream = [ + { name = "openai" }, +] nexus = [ { name = "nexus-rpc" }, ] @@ -2649,6 +2652,7 @@ langsmith-tracing = [ { name = "openai", specifier = ">=1.4.0" }, { name = "temporalio", extras = ["pydantic", "langsmith"], specifier = ">=1.27.0" }, ] +llm-stream = [{ name = "openai", specifier = ">=1.0" }] nexus = [{ name = "nexus-rpc", specifier = ">=1.1.0,<2" }] open-telemetry = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, From 51f2f2df0536223a37e228738ebb377bb7d6c163 Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:34:29 -0700 Subject: [PATCH 22/23] samples: workflow_streams: fix lint failures (ruff isort + format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `poe lint` step was failing on three small things across four files: * `run_external_publisher.py`, `ticker_workflow.py`: ruff isort (`I001`) wanted the `workflow_streams.shared` imports re-sorted and a stray blank line removed. Apply the auto-fix. * `run_external_publisher.py`, `run_reconnecting_subscriber.py`, `run_truncating_ticker.py`: ruff format wanted three line-wrapped function calls collapsed back to single lines. Apply the formatter. * `run_truncating_ticker.py`: the formatter joined an adjacent pair of f-strings into an awkward `f"..." f"..."` one-liner. Consolidate them into a single f-string for readability — the resulting line is comfortably under the 88-char limit. `poe lint` (ruff isort + ruff format --check + mypy --all-groups --check-untyped-defs) now passes locally. --- workflow_streams/run_external_publisher.py | 5 +---- workflow_streams/run_reconnecting_subscriber.py | 8 ++------ workflow_streams/run_truncating_ticker.py | 3 +-- workflow_streams/workflows/ticker_workflow.py | 2 +- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/workflow_streams/run_external_publisher.py b/workflow_streams/run_external_publisher.py index bf7d98e6..8e7d38f8 100644 --- a/workflow_streams/run_external_publisher.py +++ b/workflow_streams/run_external_publisher.py @@ -35,7 +35,6 @@ ) from workflow_streams.workflows.hub_workflow import HubWorkflow - HEADLINES = [ "rates held", "merger announced", @@ -85,9 +84,7 @@ async def publish_news() -> None: async def consume_news() -> None: consumer = WorkflowStreamClient.create(client, workflow_id) - async for item in consumer.subscribe( - [TOPIC_NEWS], result_type=NewsEvent - ): + async for item in consumer.subscribe([TOPIC_NEWS], result_type=NewsEvent): if item.data.headline == DONE_HEADLINE: return print(f"[subscriber] offset={item.offset}: {item.data.headline}") diff --git a/workflow_streams/run_reconnecting_subscriber.py b/workflow_streams/run_reconnecting_subscriber.py index ee86c965..7eca050b 100644 --- a/workflow_streams/run_reconnecting_subscriber.py +++ b/workflow_streams/run_reconnecting_subscriber.py @@ -109,9 +109,7 @@ async def poller() -> None: emit(state, "·") last_emit = now try: - await asyncio.wait_for( - stop.wait(), timeout=POLL_INTERVAL_SECONDS - ) + await asyncio.wait_for(stop.wait(), timeout=POLL_INTERVAL_SECONDS) except asyncio.TimeoutError: pass @@ -120,9 +118,7 @@ async def poller() -> None: # ---- Phase 1: connect, read a couple of events, "disconnect". emit(state, "[phase 1] connecting") seen = 0 - async for item in stream.subscribe( - [TOPIC_STATUS], result_type=StageEvent - ): + async for item in stream.subscribe([TOPIC_STATUS], result_type=StageEvent): # Remember *one past* the offset just consumed: on resume we # want the next unseen event, not the one we already showed. state.processed = item.offset + 1 diff --git a/workflow_streams/run_truncating_ticker.py b/workflow_streams/run_truncating_ticker.py index 555392e6..26399447 100644 --- a/workflow_streams/run_truncating_ticker.py +++ b/workflow_streams/run_truncating_ticker.py @@ -110,8 +110,7 @@ async def slow_subscriber() -> None: if last_offset >= 0 and item.offset > last_offset + 1: gap = item.offset - last_offset - 1 emit_slow( - f"↪ jumped offset={last_offset} → {item.offset} " - f"({gap} dropped)" + f"↪ jumped offset={last_offset} → {item.offset} ({gap} dropped)" ) emit_slow(f"offset={item.offset:>3} n={item.data.n}") last_offset = item.offset diff --git a/workflow_streams/workflows/ticker_workflow.py b/workflow_streams/workflows/ticker_workflow.py index 566b98f1..c3f37b9f 100644 --- a/workflow_streams/workflows/ticker_workflow.py +++ b/workflow_streams/workflows/ticker_workflow.py @@ -7,8 +7,8 @@ from workflow_streams.shared import ( TOPIC_TICK, - TickEvent, TickerInput, + TickEvent, ) From 2f3914683e5181266b9680afaa272ba4c2db8bae Mon Sep 17 00:00:00 2001 From: Johann Schleier-Smith Date: Sat, 2 May 2026 19:45:06 -0700 Subject: [PATCH 23/23] samples: workflow_streams: drop BFF jargon and Expected output block Two README/comment cleanups: * "BFF" (backend-for-frontend) is not a widely-known term outside certain front-end-architecture circles. Replace with the more obvious "web backends" in the README intro and "production web backend" in the run_reconnecting_subscriber.py comment about where the resume offset would live durably. * Drop the "Expected output" section. It only covered scenarios 1 and 2; with five scenarios it is no longer pulling its weight. Anyone running the script can see the output for themselves. --- workflow_streams/README.md | 39 +------------------ .../run_reconnecting_subscriber.py | 7 ++-- 2 files changed, 5 insertions(+), 41 deletions(-) diff --git a/workflow_streams/README.md b/workflow_streams/README.md index b71e39d4..f06bed39 100644 --- a/workflow_streams/README.md +++ b/workflow_streams/README.md @@ -7,7 +7,7 @@ `temporalio.contrib.workflow_streams` lets a workflow host a durable, offset-addressed event channel. The workflow holds an append-only log; -external clients (activities, starters, BFFs) publish to topics via +external clients (activities, starters, web backends) publish to topics via signals and subscribe via long-poll updates. This packages the boilerplate — batching, offset tracking, topic filtering, continue-as-new hand-off — into a reusable stream. @@ -118,40 +118,3 @@ To exercise scenario 5's retry path, kill `run_llm_worker.py` activity's next attempt sends a `RetryEvent` first; the consumer clears its on-screen output via ANSI escapes and re-renders from scratch. - -## Expected output - -Scenario 1 (basic publisher): - -``` -[status] received: order=order-1 -[progress] charging card... -[progress] card charged -[status] shipped: order=order-1 -[progress] charge id: charge-order-1 -[status] complete: order=order-1 -workflow result: charge-order-1 -``` - -Scenario 2 (reconnecting subscriber). Each line carries a stats -column on the left (`proc`, `avail`, `pend`) and a phase / event -message on the right; a background poller emits a `·` heartbeat -once a second. Offsets are continuous across the disconnect — no -events lost, none duplicated: - -``` -proc= 0 avail= 0 pend= 0 │ started workflow-stream-pipeline-... -proc= 0 avail= 1 pend= 1 │ [phase 1] connecting -proc= 1 avail= 1 pend= 0 │ offset= 0 stage=validating -proc= 2 avail= 2 pend= 0 │ offset= 1 stage=loading data -proc= 2 avail= 2 pend= 0 │ [phase 1] disconnecting -proc= 2 avail= 3 pend= 1 │ · -proc= 2 avail= 3 pend= 1 │ · -proc= 2 avail= 4 pend= 2 │ · -proc= 2 avail= 4 pend= 2 │ [phase 2] reconnecting -proc= 3 avail= 4 pend= 1 │ offset= 2 stage=transforming -proc= 4 avail= 4 pend= 0 │ offset= 3 stage=writing output -proc= 5 avail= 5 pend= 0 │ offset= 4 stage=verifying -proc= 6 avail= 6 pend= 0 │ offset= 5 stage=complete -proc= 6 avail= 6 pend= 0 │ workflow result: pipeline ... done -``` diff --git a/workflow_streams/run_reconnecting_subscriber.py b/workflow_streams/run_reconnecting_subscriber.py index 7eca050b..d0da5e32 100644 --- a/workflow_streams/run_reconnecting_subscriber.py +++ b/workflow_streams/run_reconnecting_subscriber.py @@ -86,9 +86,10 @@ async def main() -> None: task_queue=TASK_QUEUE, ) - # In a real BFF the resume offset lives in durable storage keyed by - # (user_id, run_id) — a database row, a Redis key, etc. For an - # in-process demo a State.processed attribute works the same way. + # In a production web backend the resume offset would live in + # durable storage keyed by (user_id, run_id) — a database row, a + # Redis key, etc. For an in-process demo a State.processed + # attribute works the same way. state = State() stream = WorkflowStreamClient.create(client, workflow_id) emit(state, f"started {workflow_id}")