From 1c91e2239e326c51863ac636e928b74e06b76211 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 24 Aug 2026 16:21:25 -0700 Subject: [PATCH 1/6] feat: add Workflow Streams samples --- .github/CODEOWNERS | 2 + Directory.Packages.props | 4 +- README.md | 1 + TemporalioSamples.sln | 15 + src/WorkflowStreams/HubWorkflow.workflow.cs | 24 ++ src/WorkflowStreams/LlmActivities.cs | 65 ++++ src/WorkflowStreams/LlmWorkflow.workflow.cs | 21 ++ src/WorkflowStreams/OrderWorkflow.workflow.cs | 31 ++ src/WorkflowStreams/PaymentActivities.cs | 26 ++ .../PipelineWorkflow.workflow.cs | 40 ++ src/WorkflowStreams/Program.cs | 86 +++++ src/WorkflowStreams/README.md | 88 +++++ src/WorkflowStreams/Scenarios.cs | 349 ++++++++++++++++++ src/WorkflowStreams/Shared.cs | 65 ++++ .../TemporalioSamples.WorkflowStreams.csproj | 12 + .../TickerWorkflow.workflow.cs | 38 ++ tests/TemporalioSamples.Tests.csproj | 1 + tests/WorkflowStreams/WorkflowStreamsTests.cs | 285 ++++++++++++++ 18 files changed, 1152 insertions(+), 1 deletion(-) create mode 100644 src/WorkflowStreams/HubWorkflow.workflow.cs create mode 100644 src/WorkflowStreams/LlmActivities.cs create mode 100644 src/WorkflowStreams/LlmWorkflow.workflow.cs create mode 100644 src/WorkflowStreams/OrderWorkflow.workflow.cs create mode 100644 src/WorkflowStreams/PaymentActivities.cs create mode 100644 src/WorkflowStreams/PipelineWorkflow.workflow.cs create mode 100644 src/WorkflowStreams/Program.cs create mode 100644 src/WorkflowStreams/README.md create mode 100644 src/WorkflowStreams/Scenarios.cs create mode 100644 src/WorkflowStreams/Shared.cs create mode 100644 src/WorkflowStreams/TemporalioSamples.WorkflowStreams.csproj create mode 100644 src/WorkflowStreams/TickerWorkflow.workflow.cs create mode 100644 tests/WorkflowStreams/WorkflowStreamsTests.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0f4298b..2b02cb9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,3 @@ * @temporalio/sdk +/src/WorkflowStreams/ @temporalio/sdk @temporalio/ai-sdk +/tests/WorkflowStreams/ @temporalio/sdk @temporalio/ai-sdk diff --git a/Directory.Packages.props b/Directory.Packages.props index ffa90ed..d171944 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,15 +16,17 @@ + - + + diff --git a/README.md b/README.md index 13ccd52..87a0311 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Prerequisites: * [UpdateWithStartLazyInit](src/UpdateWithStartLazyInit) - Use update with start to lazily start a workflow before sending update. * [WorkerSpecificTaskQueues](src/WorkerSpecificTaskQueues) - Use a unique task queue per Worker to have certain Activities only run on that specific Worker. * [WorkerVersioning](src/WorkerVersioning) - How to use the Worker Versioning feature to more easily deploy changes to Workflow & other code. +* [WorkflowStreams](src/WorkflowStreams) - Host a durable publish/subscribe stream inside a Workflow. * [WorkflowUpdate](src/WorkflowUpdate) - How to use the Workflow Update feature while blocking in update method for concurrent updates. ## Development diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index a3ede5c..d63e7d2 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -141,6 +141,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SearchAttributes", "SearchA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SearchAttributes", "src\SearchAttributes\TemporalioSamples.SearchAttributes.csproj", "{97376F57-BA10-464B-AFBD-583E187DE947}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams", "src\WorkflowStreams\TemporalioSamples.WorkflowStreams.csproj", "{091DC9D4-A88D-4393-9F84-32627DB1141F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -799,6 +801,18 @@ Global {97376F57-BA10-464B-AFBD-583E187DE947}.Release|x64.Build.0 = Release|Any CPU {97376F57-BA10-464B-AFBD-583E187DE947}.Release|x86.ActiveCfg = Release|Any CPU {97376F57-BA10-464B-AFBD-583E187DE947}.Release|x86.Build.0 = Release|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Debug|x64.ActiveCfg = Debug|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Debug|x64.Build.0 = Debug|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Debug|x86.ActiveCfg = Debug|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Debug|x86.Build.0 = Debug|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|Any CPU.Build.0 = Release|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x64.ActiveCfg = Release|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x64.Build.0 = Release|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x86.ActiveCfg = Release|Any CPU + {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -869,5 +883,6 @@ Global {6B6622AE-2970-4AAD-B2E4-A7EE1E2C40EA} = {17436B0C-8853-030B-9E2F-BAEB65BE59FB} {34ADAC00-5559-6993-F779-D71CE5A0F93F} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {97376F57-BA10-464B-AFBD-583E187DE947} = {34ADAC00-5559-6993-F779-D71CE5A0F93F} + {091DC9D4-A88D-4393-9F84-32627DB1141F} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} EndGlobalSection EndGlobal diff --git a/src/WorkflowStreams/HubWorkflow.workflow.cs b/src/WorkflowStreams/HubWorkflow.workflow.cs new file mode 100644 index 0000000..6c5c1f7 --- /dev/null +++ b/src/WorkflowStreams/HubWorkflow.workflow.cs @@ -0,0 +1,24 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Workflows; + +[Workflow] +public class HubWorkflow +{ + private bool closed; + + [WorkflowInit] + public HubWorkflow(HubInput input) => _ = new WorkflowStream(input.StreamState); + + [WorkflowRun] + public async Task RunAsync(HubInput input) + { + await Workflow.WaitConditionAsync(() => closed); + await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + return $"hub {input.HubId} closed"; + } + + [WorkflowSignal] + public async Task CloseAsync() => closed = true; +} diff --git a/src/WorkflowStreams/LlmActivities.cs b/src/WorkflowStreams/LlmActivities.cs new file mode 100644 index 0000000..f66fc1d --- /dev/null +++ b/src/WorkflowStreams/LlmActivities.cs @@ -0,0 +1,65 @@ +namespace TemporalioSamples.WorkflowStreams; + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Text; +using OpenAI; +using OpenAI.Chat; +using Temporalio.Activities; +using Temporalio.Extensions.WorkflowStreams; + +public static class LlmActivities +{ + [Activity] + public static async Task StreamCompletionAsync(LlmInput input) + { + var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("OPENAI_API_KEY must be set for the LLM scenario"); + } + + await using var streamClient = WorkflowStreamClient.FromActivity( + new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); + var deltas = streamClient.Topic(WorkflowStreamsConstants.TopicDelta); + var complete = streamClient.Topic(WorkflowStreamsConstants.TopicComplete); + var retry = streamClient.Topic(WorkflowStreamsConstants.TopicRetry); + + var activityContext = ActivityExecutionContext.Current; + if (activityContext.Info.Attempt > 1) + { + retry.Publish(new RetryEvent(activityContext.Info.Attempt), forceFlush: true); + } + + var clientOptions = new OpenAIClientOptions + { + RetryPolicy = new ClientRetryPolicy(0), + }; + var chatClient = new ChatClient( + input.Model, + new ApiKeyCredential(apiKey), + clientOptions); + + var fullText = new StringBuilder(); + var updates = chatClient.CompleteChatStreamingAsync( + [new UserChatMessage(input.Prompt)], + cancellationToken: activityContext.CancellationToken); + await foreach (var update in updates) + { + foreach (var contentPart in update.ContentUpdate) + { + if (string.IsNullOrEmpty(contentPart.Text)) + { + continue; + } + deltas.Publish(new TextDelta(contentPart.Text)); + fullText.Append(contentPart.Text); + } + } + + var result = fullText.ToString(); + complete.Publish(new TextComplete(result), forceFlush: true); + await streamClient.FlushAsync(); + return result; + } +} diff --git a/src/WorkflowStreams/LlmWorkflow.workflow.cs b/src/WorkflowStreams/LlmWorkflow.workflow.cs new file mode 100644 index 0000000..8752cde --- /dev/null +++ b/src/WorkflowStreams/LlmWorkflow.workflow.cs @@ -0,0 +1,21 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Workflows; + +[Workflow] +public class LlmWorkflow +{ + [WorkflowInit] + public LlmWorkflow(LlmInput input) => _ = new WorkflowStream(input.StreamState); + + [WorkflowRun] + public async Task RunAsync(LlmInput input) + { + var result = await Workflow.ExecuteActivityAsync( + () => LlmActivities.StreamCompletionAsync(input), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(2), }); + await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + return result; + } +} diff --git a/src/WorkflowStreams/OrderWorkflow.workflow.cs b/src/WorkflowStreams/OrderWorkflow.workflow.cs new file mode 100644 index 0000000..68bf23f --- /dev/null +++ b/src/WorkflowStreams/OrderWorkflow.workflow.cs @@ -0,0 +1,31 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Workflows; + +[Workflow] +public class OrderWorkflow +{ + private readonly WorkflowStream stream; + + [WorkflowInit] + public OrderWorkflow(OrderInput input) => stream = new(input.StreamState); + + [WorkflowRun] + public async Task RunAsync(OrderInput input) + { + var status = stream.Topic(WorkflowStreamsConstants.TopicStatus); + var progress = stream.Topic(WorkflowStreamsConstants.TopicProgress); + + status.Publish(new StatusEvent("received", input.OrderId)); + var chargeId = await Workflow.ExecuteActivityAsync( + () => PaymentActivities.ChargeCardAsync(input.OrderId), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(1), }); + status.Publish(new StatusEvent("shipped", input.OrderId)); + progress.Publish(new ProgressEvent($"charge id: {chargeId}")); + status.Publish(new StatusEvent("complete", input.OrderId)); + + await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + return chargeId; + } +} diff --git a/src/WorkflowStreams/PaymentActivities.cs b/src/WorkflowStreams/PaymentActivities.cs new file mode 100644 index 0000000..67ba852 --- /dev/null +++ b/src/WorkflowStreams/PaymentActivities.cs @@ -0,0 +1,26 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Microsoft.Extensions.Logging; +using Temporalio.Activities; +using Temporalio.Extensions.WorkflowStreams; + +public static class PaymentActivities +{ + [Activity] + public static async Task ChargeCardAsync(string orderId) + { + await using var streamClient = WorkflowStreamClient.FromActivity( + new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); + var progress = streamClient.Topic(WorkflowStreamsConstants.TopicProgress); + + progress.Publish(new ProgressEvent("charging card...")); + ActivityExecutionContext.Current.Logger.LogInformation( + "Charging card for order {OrderId}", orderId); + await Task.Delay( + TimeSpan.FromSeconds(1), + ActivityExecutionContext.Current.CancellationToken); + progress.Publish(new ProgressEvent("card charged")); + await streamClient.FlushAsync(); + return $"charge-{orderId}"; + } +} diff --git a/src/WorkflowStreams/PipelineWorkflow.workflow.cs b/src/WorkflowStreams/PipelineWorkflow.workflow.cs new file mode 100644 index 0000000..6f8f76e --- /dev/null +++ b/src/WorkflowStreams/PipelineWorkflow.workflow.cs @@ -0,0 +1,40 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Workflows; + +[Workflow] +public class PipelineWorkflow +{ + private readonly WorkflowStream stream; + + [WorkflowInit] + public PipelineWorkflow(PipelineInput input) => stream = new(input.StreamState); + + [WorkflowRun] + public async Task RunAsync(PipelineInput input) + { + var status = stream.Topic(WorkflowStreamsConstants.TopicStatus); + var stageInterval = input.StageInterval ?? TimeSpan.FromSeconds(2); + var stages = new[] + { + "validating", + "loading data", + "transforming", + "writing output", + "verifying", + "complete", + }; + foreach (var stage in stages) + { + status.Publish(new StageEvent(stage)); + if (stage != "complete") + { + await Workflow.DelayAsync(stageInterval); + } + } + + await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + return $"pipeline {input.PipelineId} done"; + } +} diff --git a/src/WorkflowStreams/Program.cs b/src/WorkflowStreams/Program.cs new file mode 100644 index 0000000..9afbad9 --- /dev/null +++ b/src/WorkflowStreams/Program.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +async Task RunWorkerAsync(bool llm) +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + + var workerOptions = new TemporalWorkerOptions( + llm ? WorkflowStreamsConstants.LlmTaskQueue : WorkflowStreamsConstants.TaskQueue); + if (llm) + { + workerOptions. + AddActivity(LlmActivities.StreamCompletionAsync). + AddWorkflow(); + } + else + { + workerOptions. + AddActivity(PaymentActivities.ChargeCardAsync). + AddWorkflow(). + AddWorkflow(). + AddWorkflow(). + AddWorkflow(); + } + + Console.WriteLine($"Running {(llm ? "LLM " : string.Empty)}worker"); + using var worker = new TemporalWorker(client, workerOptions); + try + { + await worker.ExecuteAsync(tokenSource.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine("Worker cancelled"); + } +} + +switch (args.ElementAtOrDefault(0)) +{ + case "worker": + await RunWorkerAsync(llm: false); + break; + case "publisher": + await Scenarios.RunPublisherAsync(client); + break; + case "listener": + await Scenarios.RunListenerAsync(client); + break; + case "reconnecting": + await Scenarios.RunReconnectingSubscriberAsync(client); + break; + case "external-publisher": + await Scenarios.RunExternalPublisherAsync(client); + break; + case "ticker": + await Scenarios.RunTruncatingTickerAsync(client); + break; + case "llm-worker": + await RunWorkerAsync(llm: true); + break; + case "llm": + await Scenarios.RunLlmAsync( + client, + args.Length > 1 ? string.Join(' ', args.Skip(1)) : null); + break; + default: + throw new ArgumentException( + "Must pass 'worker', 'publisher', 'listener', 'reconnecting', " + + "'external-publisher', 'ticker', 'llm-worker', or 'llm'"); +} diff --git a/src/WorkflowStreams/README.md b/src/WorkflowStreams/README.md new file mode 100644 index 0000000..7600388 --- /dev/null +++ b/src/WorkflowStreams/README.md @@ -0,0 +1,88 @@ +# Workflow Streams + +> **Experimental.** This sample uses `Temporalio.Extensions.WorkflowStreams`, whose API may +> change in future versions. + +A workflow stream is a durable, offset-addressed publish/subscribe log hosted inside a Temporal +Workflow. Workflow code and external clients publish to named topics through Signals, subscribers +long-poll through Updates, and a Query exposes the current global offset. The extension handles +batching, publisher deduplication, topic filtering, Continue-As-New handoff, and truncation. + +The sample contains six scenarios. The first five commands use the shared worker; LLM streaming +uses a separate worker and task queue because it requires an OpenAI API key. + +## Run the sample + +First see the [repository README](../../README.md) for prerequisites and start a Temporal service, +for example with `temporal server start-dev`. + +Start the shared worker: + +```bash +dotnet run -- worker +``` + +Then run a scenario from another terminal: + +```bash +dotnet run -- publisher # Basic mixed-topic publish/subscribe +dotnet run -- listener # Listener callbacks and task-based backpressure +dotnet run -- reconnecting # Disconnect and resume from a saved offset +dotnet run -- external-publisher # Publish from code outside a Workflow or Activity +dotnet run -- ticker # Bound the log by truncating old entries +``` + +### Basic publish/subscribe + +`OrderWorkflow` publishes lifecycle events to the `status` topic. Its payment Activity creates a +client with `WorkflowStreamClient.FromActivity()` and publishes finer-grained events to `progress`. +The subscriber consumes both topics with `await foreach` and decodes each payload by topic. + +### Listener subscription + +The listener scenario starts two order Workflows and subscribes to each with +`WorkflowStreamListener`. `Subscribe` returns immediately with a +`WorkflowStreamSubscriptionHandle`. Callbacks are serialized per subscription, and the Task +returned by `OnNextAsync` prevents the next item and poll from being delivered until processing +finishes. Unlike a blocking iterator, both this API and .NET's `await foreach` API poll fully +asynchronously without occupying a thread. + +### Reconnecting subscriber + +The first subscriber reads two pipeline stages and saves one past the last global offset it saw. +A fresh client then calls `Subscribe(savedOffset)` and receives the remaining stages without gaps +or duplicates. Offsets are global across topics, not per-topic. + +### External publisher + +`HubWorkflow` only hosts the stream. A normal client publishes news into it while another client +subscribes, then signals the Workflow to close after flushing a sentinel event. + +### Bounded log + +`TickerWorkflow` periodically calls `Truncate()` to retain only recent events. A fast subscriber +sees every tick. A late subscriber requests an offset that has already been truncated and is +automatically advanced to the retained base offset, demonstrating the bounded-history tradeoff. + +## LLM token streaming + +Set `OPENAI_API_KEY` and start the dedicated worker: + +```bash +export OPENAI_API_KEY=... +dotnet run -- llm-worker +``` + +Then run the subscriber with an optional prompt: + +```bash +dotnet run -- llm +dotnet run -- llm "Explain durable execution in one paragraph." +``` + +The Workflow performs no network I/O. Its Activity calls OpenAI with streaming enabled, publishes +token deltas to `delta`, and publishes the final text to `complete`. OpenAI's own retries are +disabled so Temporal owns retry behavior. When an Activity retry starts, it publishes to `retry`; +the subscriber clears the partial terminal output and renders the new attempt from scratch. + +To exercise that path, stop the LLM worker while output is streaming and restart it. diff --git a/src/WorkflowStreams/Scenarios.cs b/src/WorkflowStreams/Scenarios.cs new file mode 100644 index 0000000..0fb4906 --- /dev/null +++ b/src/WorkflowStreams/Scenarios.cs @@ -0,0 +1,349 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Converters; +using Temporalio.Extensions.WorkflowStreams; + +public static class Scenarios +{ + private const int TickCount = 30; + private const int KeepLast = 5; + private const int TruncateEvery = 5; + private const long StaleOffset = 1; + + private static readonly string[] Headlines = + [ + "markets open higher", + "new bridge opens downtown", + "local team wins championship", + ]; + + private static readonly string[] OrderIds = new[] { "order-A", "order-B" }; + + public static async Task RunPublisherAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-order-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-42", null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var options = new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicStatus, + WorkflowStreamsConstants.TopicProgress, + }, + }; + await foreach (var item in streamClient.Subscribe(options)) + { + if (item.Topic == WorkflowStreamsConstants.TopicStatus) + { + var evt = Decode(client, item); + Console.WriteLine($"[status] {evt.Kind}: order={evt.OrderId}"); + if (evt.Kind == "complete") + { + break; + } + } + else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + { + var evt = Decode(client, item); + Console.WriteLine($"[progress] {evt.Message}"); + } + } + + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } + + public static async Task RunListenerAsync(ITemporalClient client) + { + var workflowHandles = new List>(); + var streamClients = new List(); + var subscriptionHandles = new List(); + using var renderLock = new SemaphoreSlim(1, 1); + + foreach (var orderId in OrderIds) + { + var workflowId = $"workflow-streams-listener-{orderId}-{Guid.NewGuid()}"; + var workflowHandle = await client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput(orderId, null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + +#pragma warning disable CA2000 // The client is closed in the method's finally block + var streamClient = new WorkflowStreamClient(client, workflowId); +#pragma warning restore CA2000 + var listener = new RenderingListener(client, orderId, renderLock); + var subscriptionHandle = streamClient.Subscribe( + new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicStatus, + WorkflowStreamsConstants.TopicProgress, + }, + }, + listener); + workflowHandles.Add(workflowHandle); + streamClients.Add(streamClient); + subscriptionHandles.Add(subscriptionHandle); + } + + try + { + await Task.WhenAll(subscriptionHandles.Select(handle => handle.Completion)); + for (var i = 0; i < workflowHandles.Count; i++) + { + var result = await workflowHandles[i].GetResultAsync(); + Console.WriteLine($"[{OrderIds[i]}] workflow result: {result}"); + } + } + finally + { + foreach (var handle in subscriptionHandles) + { + handle.Dispose(); + } + foreach (var streamClient in streamClients) + { + await streamClient.CloseAsync(); + } + } + } + + public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-pipeline-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (PipelineWorkflow wf) => wf.RunAsync(new PipelineInput("pipeline-7", null, null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + long nextOffset = 0; + Console.WriteLine("--- phase 1: initial subscriber ---"); + await using (var streamClient = new WorkflowStreamClient(client, workflowId)) + { + var seen = 0; + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe()) + { + var evt = Decode(client, item); + nextOffset = item.Offset + 1; + Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); + if (++seen == 2) + { + break; + } + } + } + + Console.WriteLine($"--- disconnected; will resume from offset {nextOffset} ---"); + Console.WriteLine("--- phase 2: reconnected subscriber ---"); + await using (var streamClient = new WorkflowStreamClient(client, workflowId)) + { + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe(nextOffset)) + { + var evt = Decode(client, item); + Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); + if (evt.Stage == "complete") + { + break; + } + } + } + + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } + + public static async Task RunExternalPublisherAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-hub-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (HubWorkflow wf) => wf.RunAsync(new HubInput("newsroom", null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + async Task SubscribeAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicNews).Subscribe()) + { + var evt = Decode(client, item); + if (evt.Headline == WorkflowStreamsConstants.DoneHeadline) + { + break; + } + Console.WriteLine($"[subscriber] {evt.Headline}"); + } + } + + async Task PublishAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var news = streamClient.Topic(WorkflowStreamsConstants.TopicNews); + foreach (var headline in Headlines) + { + news.Publish(new NewsEvent(headline)); + Console.WriteLine($"[publisher] sent: {headline}"); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + } + news.Publish(new NewsEvent(WorkflowStreamsConstants.DoneHeadline), forceFlush: true); + await streamClient.FlushAsync(); + await handle.SignalAsync(wf => wf.CloseAsync()); + Console.WriteLine("[publisher] signaled close"); + } + + await Task.WhenAll(SubscribeAsync(), PublishAsync()); + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } + + public static async Task RunTruncatingTickerAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-ticker-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (TickerWorkflow wf) => wf.RunAsync( + new TickerInput(TickCount, KeepLast, TruncateEvery, null, null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + async Task FastSubscriberAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe()) + { + var evt = Decode(client, item); + Console.WriteLine($"[fast] offset={item.Offset,3} n={evt.N}"); + if (evt.N == TickCount - 1) + { + break; + } + } + } + + async Task LateSubscriberAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var firstTruncate = ((KeepLast / TruncateEvery) + 1) * TruncateEvery; + while (await streamClient.GetOffsetAsync() <= firstTruncate) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + var first = true; + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe(StaleOffset)) + { + var evt = Decode(client, item); + if (first && item.Offset > StaleOffset) + { + Console.WriteLine( + $"[late] requested offset {StaleOffset} but it was truncated; " + + $"fast-forwarded to offset {item.Offset} " + + $"(skipped {item.Offset - StaleOffset} tick(s))"); + } + first = false; + Console.WriteLine($"[late] offset={item.Offset,3} n={evt.N}"); + if (evt.N == TickCount - 1) + { + break; + } + } + } + + await Task.WhenAll(FastSubscriberAsync(), LateSubscriberAsync()); + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } + + public static async Task RunLlmAsync(ITemporalClient client, string? prompt) + { + const string ansiSave = "\u001b[s"; + const string ansiRestoreAndClear = "\u001b[u\u001b[J"; + prompt ??= + "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 practical implementation tradeoffs."; + + var input = new LlmInput(prompt); + var workflowId = $"workflow-streams-llm-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (LlmWorkflow wf) => wf.RunAsync(input), + new(workflowId, WorkflowStreamsConstants.LlmTaskQueue)); + Console.WriteLine( + $"[llm {workflowId}] streaming response from {input.Model}, awaiting first token..."); + Console.WriteLine(); + Console.Write(ansiSave); + + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var options = new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicDelta, + WorkflowStreamsConstants.TopicRetry, + WorkflowStreamsConstants.TopicComplete, + }, + }; + await foreach (var item in streamClient.Subscribe(options)) + { + if (item.Topic == WorkflowStreamsConstants.TopicRetry) + { + var evt = Decode(client, item); + Console.Write(ansiRestoreAndClear); + Console.WriteLine($"[retry attempt {evt.Attempt}] resetting output"); + Console.WriteLine(); + Console.Write(ansiSave); + } + else if (item.Topic == WorkflowStreamsConstants.TopicDelta) + { + Console.Write(Decode(client, item).Text); + } + else if (item.Topic == WorkflowStreamsConstants.TopicComplete) + { + _ = Decode(client, item); + Console.WriteLine(); + break; + } + } + + var result = await handle.GetResultAsync(); + Console.WriteLine($"[workflow result: {result.Length} chars]"); + } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); + + private sealed class RenderingListener( + ITemporalClient client, + string orderId, + SemaphoreSlim renderLock) : WorkflowStreamListener + { + public override async Task OnNextAsync(WorkflowStreamItem item) + { + await renderLock.WaitAsync().ConfigureAwait(false); + try + { + if (item.Topic == WorkflowStreamsConstants.TopicStatus) + { + Console.WriteLine( + $"[{orderId}] [status] {Decode(client, item).Kind}"); + } + else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + { + Console.WriteLine( + $"[{orderId}] [progress] {Decode(client, item).Message}"); + } + } + finally + { + renderLock.Release(); + } + } + + public override void OnCompleted() => + Console.WriteLine($"[{orderId}] stream completed"); + + public override void OnError(Exception failure) => + Console.Error.WriteLine($"[{orderId}] stream failed: {failure}"); + } +} diff --git a/src/WorkflowStreams/Shared.cs b/src/WorkflowStreams/Shared.cs new file mode 100644 index 0000000..42a1f43 --- /dev/null +++ b/src/WorkflowStreams/Shared.cs @@ -0,0 +1,65 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; + +public static class WorkflowStreamsConstants +{ + public const string TaskQueue = "workflow-streams"; + + public const string LlmTaskQueue = "workflow-streams-llm"; + + public const string TopicStatus = "status"; + + public const string TopicProgress = "progress"; + + public const string TopicNews = "news"; + + public const string TopicTick = "tick"; + + public const string TopicDelta = "delta"; + + public const string TopicComplete = "complete"; + + public const string TopicRetry = "retry"; + + public const string DoneHeadline = "-- end of feed --"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} + +public record OrderInput(string OrderId, WorkflowStreamState? StreamState = null); + +public record PipelineInput( + string PipelineId, + TimeSpan? StageInterval = null, + WorkflowStreamState? StreamState = null); + +public record HubInput(string HubId, WorkflowStreamState? StreamState = null); + +public record TickerInput( + int Count = 50, + int KeepLast = 10, + int TruncateEvery = 5, + TimeSpan? Interval = null, + WorkflowStreamState? StreamState = null); + +public record LlmInput( + string Prompt, + string Model = "gpt-4o-mini", + WorkflowStreamState? StreamState = null); + +public record StatusEvent(string Kind, string OrderId); + +public record ProgressEvent(string Message); + +public record StageEvent(string Stage); + +public record NewsEvent(string Headline); + +public record TickEvent(int N); + +public record TextDelta(string Text); + +public record TextComplete(string FullText); + +public record RetryEvent(int Attempt); diff --git a/src/WorkflowStreams/TemporalioSamples.WorkflowStreams.csproj b/src/WorkflowStreams/TemporalioSamples.WorkflowStreams.csproj new file mode 100644 index 0000000..3879625 --- /dev/null +++ b/src/WorkflowStreams/TemporalioSamples.WorkflowStreams.csproj @@ -0,0 +1,12 @@ + + + + Exe + + + + + + + + diff --git a/src/WorkflowStreams/TickerWorkflow.workflow.cs b/src/WorkflowStreams/TickerWorkflow.workflow.cs new file mode 100644 index 0000000..062904e --- /dev/null +++ b/src/WorkflowStreams/TickerWorkflow.workflow.cs @@ -0,0 +1,38 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Workflows; + +[Workflow] +public class TickerWorkflow +{ + private readonly WorkflowStream stream; + + [WorkflowInit] + public TickerWorkflow(TickerInput input) => stream = new(input.StreamState); + + [WorkflowRun] + public async Task RunAsync(TickerInput input) + { + var tick = stream.Topic(WorkflowStreamsConstants.TopicTick); + var interval = input.Interval ?? TimeSpan.FromMilliseconds(200); + + for (var n = 0; n < input.Count; n++) + { + tick.Publish(new TickEvent(n)); + if (interval > TimeSpan.Zero) + { + await Workflow.DelayAsync(interval); + } + + var published = n + 1; + if (published % input.TruncateEvery == 0 && published > input.KeepLast) + { + stream.Truncate(published - input.KeepLast); + } + } + + await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + return $"ticker emitted {input.Count} events"; + } +} diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj index 234002a..f143517 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -39,6 +39,7 @@ + diff --git a/tests/WorkflowStreams/WorkflowStreamsTests.cs b/tests/WorkflowStreams/WorkflowStreamsTests.cs new file mode 100644 index 0000000..cee5d64 --- /dev/null +++ b/tests/WorkflowStreams/WorkflowStreamsTests.cs @@ -0,0 +1,285 @@ +namespace TemporalioSamples.Tests.WorkflowStreams; + +using Temporalio.Activities; +using Temporalio.Client; +using Temporalio.Converters; +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams; +using Xunit; +using Xunit.Abstractions; + +public class WorkflowStreamsTests : WorkflowEnvironmentTestBase +{ + private static readonly string[] ExpectedOrderStatuses = + ["received", "shipped", "complete"]; + + public WorkflowStreamsTests(ITestOutputHelper output, WorkflowEnvironment env) + : base(output, env) + { + } + + [Fact] + public async Task OrderWorkflow_PublishesWorkflowAndActivityEvents() + { + using var worker = new TemporalWorker( + Client, + NewWorker(). + AddActivity(PaymentActivities.ChargeCardAsync). + AddWorkflow()); + await worker.ExecuteAsync(async () => + { + var workflowId = $"workflow-streams-order-{Guid.NewGuid()}"; + var handle = await Client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-42", null)), + new(workflowId, worker.Options.TaskQueue!)); + await using var streamClient = new WorkflowStreamClient(Client, workflowId); + + var statuses = new List(); + var progressCount = 0; + await foreach (var item in streamClient.Subscribe(new() + { + Topics = new List + { + WorkflowStreamsConstants.TopicStatus, + WorkflowStreamsConstants.TopicProgress, + }, + })) + { + if (item.Topic == WorkflowStreamsConstants.TopicStatus) + { + var status = Decode(item); + statuses.Add(status.Kind); + if (status.Kind == "complete") + { + break; + } + } + else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + { + progressCount++; + } + } + + Assert.Equal("charge-order-42", await handle.GetResultAsync()); + Assert.Equal(ExpectedOrderStatuses, statuses); + Assert.True(progressCount >= 2); + }); + } + + [Fact] + public async Task ListenerSubscription_DeliversSerializedCallbacks() + { + using var worker = new TemporalWorker( + Client, + NewWorker(). + AddActivity(PaymentActivities.ChargeCardAsync). + AddWorkflow()); + await worker.ExecuteAsync(async () => + { + var workflowId = $"workflow-streams-listener-{Guid.NewGuid()}"; + var handle = await Client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-listener", null)), + new(workflowId, worker.Options.TaskQueue!)); + await using var streamClient = new WorkflowStreamClient(Client, workflowId); + var listener = new RecordingListener(); + using var subscription = streamClient.Subscribe( + new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicStatus, + WorkflowStreamsConstants.TopicProgress, + }, + }, + listener); + + await subscription.Completion; + Assert.Equal("charge-order-listener", await handle.GetResultAsync()); + Assert.Equal(1, listener.MaxConcurrentCallbacks); + Assert.Equal(ExpectedOrderStatuses, listener.Statuses); + Assert.True(listener.Completed); + }); + } + + [Fact] + public async Task ReconnectingSubscriber_ResumesAtNextOffset() + { + using var worker = new TemporalWorker( + Client, + NewWorker().AddWorkflow()); + await worker.ExecuteAsync(async () => + { + var workflowId = $"workflow-streams-pipeline-{Guid.NewGuid()}"; + var handle = await Client.StartWorkflowAsync( + (PipelineWorkflow wf) => wf.RunAsync( + new PipelineInput( + "pipeline-test", + TimeSpan.FromMilliseconds(50), + null)), + new(workflowId, worker.Options.TaskQueue!)); + + var offsets = new List(); + long nextOffset = 0; + await using (var firstClient = new WorkflowStreamClient(Client, workflowId)) + { + await foreach (var item in firstClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe()) + { + offsets.Add(item.Offset); + nextOffset = item.Offset + 1; + if (offsets.Count == 2) + { + break; + } + } + } + + var remainingStages = new List(); + await using (var secondClient = new WorkflowStreamClient(Client, workflowId)) + { + await foreach (var item in secondClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe(nextOffset)) + { + offsets.Add(item.Offset); + var stage = Decode(item).Stage; + remainingStages.Add(stage); + if (stage == "complete") + { + break; + } + } + } + + Assert.Equal("pipeline pipeline-test done", await handle.GetResultAsync()); + Assert.Equal(offsets.Distinct().Count(), offsets.Count); + Assert.Equal(nextOffset, offsets[2]); + Assert.Equal("complete", remainingStages[^1]); + }); + } + + [Fact] + public async Task ExternalPublisher_PublishesAndClosesHub() + { + using var worker = new TemporalWorker( + Client, + NewWorker().AddWorkflow()); + await worker.ExecuteAsync(async () => + { + var workflowId = $"workflow-streams-hub-{Guid.NewGuid()}"; + var handle = await Client.StartWorkflowAsync( + (HubWorkflow wf) => wf.RunAsync(new HubInput("test-hub", null)), + new(workflowId, worker.Options.TaskQueue!)); + await using var subscriber = new WorkflowStreamClient(Client, workflowId); + await using var publisher = new WorkflowStreamClient(Client, workflowId); + publisher.Topic(WorkflowStreamsConstants.TopicNews).Publish(new NewsEvent("test headline"), forceFlush: true); + await publisher.FlushAsync(); + + await foreach (var item in subscriber.Topic(WorkflowStreamsConstants.TopicNews).Subscribe()) + { + Assert.Equal("test headline", Decode(item).Headline); + break; + } + await handle.SignalAsync(wf => wf.CloseAsync()); + Assert.Equal("hub test-hub closed", await handle.GetResultAsync()); + }); + } + + [Fact] + public async Task TruncatingTicker_FastForwardsStaleOffset() + { + using var worker = new TemporalWorker( + Client, + NewWorker().AddWorkflow()); + await worker.ExecuteAsync(async () => + { + var workflowId = $"workflow-streams-ticker-{Guid.NewGuid()}"; + var handle = await Client.StartWorkflowAsync( + (TickerWorkflow wf) => wf.RunAsync( + new TickerInput(20, 5, 5, TimeSpan.Zero, null)), + new(workflowId, worker.Options.TaskQueue!)); + await using var streamClient = new WorkflowStreamClient(Client, workflowId); + + await AssertMore.EventuallyAsync(async () => + Assert.True(await streamClient.GetOffsetAsync() >= 10)); + + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe(1)) + { + Assert.True(item.Offset >= 5); + break; + } + Assert.Equal("ticker emitted 20 events", await handle.GetResultAsync()); + }); + } + + [Fact] + public async Task LlmWorkflow_ReturnsMockedStreamingResult() + { + [Activity("StreamCompletion")] + static Task StreamCompletionAsync(LlmInput input) => + Task.FromResult("a streamed answer"); + + using var worker = new TemporalWorker( + Client, + NewWorker(). + AddActivity(StreamCompletionAsync). + AddWorkflow()); + await worker.ExecuteAsync(async () => + { + var result = await Client.ExecuteWorkflowAsync( + (LlmWorkflow wf) => wf.RunAsync(new LlmInput("hello", "gpt-4o-mini", null)), + new($"workflow-streams-llm-{Guid.NewGuid()}", worker.Options.TaskQueue!)); + Assert.Equal("a streamed answer", result); + }); + } + + private TemporalWorkerOptions NewWorker() => + new($"workflow-streams-{Guid.NewGuid()}"); + + private T Decode(WorkflowStreamItem item) => + Client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); + + private sealed class RecordingListener : WorkflowStreamListener + { + private readonly object lockObj = new(); + private readonly List statuses = new(); + private int activeCallbacks; + + public bool Completed { get; private set; } + + public int MaxConcurrentCallbacks { get; private set; } + + public IReadOnlyCollection Statuses + { + get + { + lock (lockObj) + { + return statuses.ToArray(); + } + } + } + + public override async Task OnNextAsync(WorkflowStreamItem item) + { + var active = Interlocked.Increment(ref activeCallbacks); + MaxConcurrentCallbacks = Math.Max(MaxConcurrentCallbacks, active); + try + { + await Task.Yield(); + if (item.Topic == WorkflowStreamsConstants.TopicStatus) + { + lock (lockObj) + { + statuses.Add(DataConverter.Default.PayloadConverter. + ToValue(item.Payload).Kind); + } + } + } + finally + { + _ = Interlocked.Decrement(ref activeCallbacks); + } + } + + public override void OnCompleted() => Completed = true; + } +} From 5e0ba8fd09e6c413e9d95e97feb3fd1d42eb8412 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 24 Aug 2026 16:46:39 -0700 Subject: [PATCH 2/6] Apply suggestion from @brianstrauch --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2b02cb9..7e7ee77 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ * @temporalio/sdk -/src/WorkflowStreams/ @temporalio/sdk @temporalio/ai-sdk +/src/WorkflowStreams/ @temporalio/sdk @temporalio/ai-sdk /tests/WorkflowStreams/ @temporalio/sdk @temporalio/ai-sdk From 4cba2aaa363ad0ef3cdfca28ef675e08ed75eca1 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 24 Aug 2026 16:56:48 -0700 Subject: [PATCH 3/6] Organize workflow stream scenarios into directories --- .../BasicPublishSubscribe/Models.cs | 9 + .../OrderWorkflow.workflow.cs | 0 .../PaymentActivities.cs | 0 .../BasicPublishSubscribe/Scenario.cs | 45 +++ src/WorkflowStreams/BoundedLog/Models.cs | 12 + src/WorkflowStreams/BoundedLog/Scenario.cs | 68 ++++ .../TickerWorkflow.workflow.cs | 0 .../HubWorkflow.workflow.cs | 0 .../ExternalPublisher/Models.cs | 7 + .../ExternalPublisher/Scenario.cs | 56 +++ .../ListenerSubscription/Scenario.cs | 98 +++++ .../{ => LlmTokenStreaming}/LlmActivities.cs | 0 .../LlmWorkflow.workflow.cs | 0 .../LlmTokenStreaming/Models.cs | 14 + .../LlmTokenStreaming/Scenario.cs | 62 ++++ src/WorkflowStreams/README.md | 25 +- .../ReconnectingSubscriber/Models.cs | 10 + .../PipelineWorkflow.workflow.cs | 0 .../ReconnectingSubscriber/Scenario.cs | 50 +++ src/WorkflowStreams/Scenarios.cs | 340 +----------------- src/WorkflowStreams/Shared.cs | 39 -- 21 files changed, 449 insertions(+), 386 deletions(-) create mode 100644 src/WorkflowStreams/BasicPublishSubscribe/Models.cs rename src/WorkflowStreams/{ => BasicPublishSubscribe}/OrderWorkflow.workflow.cs (100%) rename src/WorkflowStreams/{ => BasicPublishSubscribe}/PaymentActivities.cs (100%) create mode 100644 src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs create mode 100644 src/WorkflowStreams/BoundedLog/Models.cs create mode 100644 src/WorkflowStreams/BoundedLog/Scenario.cs rename src/WorkflowStreams/{ => BoundedLog}/TickerWorkflow.workflow.cs (100%) rename src/WorkflowStreams/{ => ExternalPublisher}/HubWorkflow.workflow.cs (100%) create mode 100644 src/WorkflowStreams/ExternalPublisher/Models.cs create mode 100644 src/WorkflowStreams/ExternalPublisher/Scenario.cs create mode 100644 src/WorkflowStreams/ListenerSubscription/Scenario.cs rename src/WorkflowStreams/{ => LlmTokenStreaming}/LlmActivities.cs (100%) rename src/WorkflowStreams/{ => LlmTokenStreaming}/LlmWorkflow.workflow.cs (100%) create mode 100644 src/WorkflowStreams/LlmTokenStreaming/Models.cs create mode 100644 src/WorkflowStreams/LlmTokenStreaming/Scenario.cs create mode 100644 src/WorkflowStreams/ReconnectingSubscriber/Models.cs rename src/WorkflowStreams/{ => ReconnectingSubscriber}/PipelineWorkflow.workflow.cs (100%) create mode 100644 src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Models.cs b/src/WorkflowStreams/BasicPublishSubscribe/Models.cs new file mode 100644 index 0000000..5501480 --- /dev/null +++ b/src/WorkflowStreams/BasicPublishSubscribe/Models.cs @@ -0,0 +1,9 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; + +public record OrderInput(string OrderId, WorkflowStreamState? StreamState = null); + +public record StatusEvent(string Kind, string OrderId); + +public record ProgressEvent(string Message); diff --git a/src/WorkflowStreams/OrderWorkflow.workflow.cs b/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs similarity index 100% rename from src/WorkflowStreams/OrderWorkflow.workflow.cs rename to src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs diff --git a/src/WorkflowStreams/PaymentActivities.cs b/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs similarity index 100% rename from src/WorkflowStreams/PaymentActivities.cs rename to src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs b/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs new file mode 100644 index 0000000..05bd8d4 --- /dev/null +++ b/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs @@ -0,0 +1,45 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Extensions.WorkflowStreams; + +public static partial class Scenarios +{ + public static async Task RunPublisherAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-order-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-42", null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var options = new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicStatus, + WorkflowStreamsConstants.TopicProgress, + }, + }; + await foreach (var item in streamClient.Subscribe(options)) + { + if (item.Topic == WorkflowStreamsConstants.TopicStatus) + { + var evt = Decode(client, item); + Console.WriteLine($"[status] {evt.Kind}: order={evt.OrderId}"); + if (evt.Kind == "complete") + { + break; + } + } + else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + { + var evt = Decode(client, item); + Console.WriteLine($"[progress] {evt.Message}"); + } + } + + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } +} diff --git a/src/WorkflowStreams/BoundedLog/Models.cs b/src/WorkflowStreams/BoundedLog/Models.cs new file mode 100644 index 0000000..c0172c9 --- /dev/null +++ b/src/WorkflowStreams/BoundedLog/Models.cs @@ -0,0 +1,12 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; + +public record TickerInput( + int Count = 50, + int KeepLast = 10, + int TruncateEvery = 5, + TimeSpan? Interval = null, + WorkflowStreamState? StreamState = null); + +public record TickEvent(int N); diff --git a/src/WorkflowStreams/BoundedLog/Scenario.cs b/src/WorkflowStreams/BoundedLog/Scenario.cs new file mode 100644 index 0000000..02598ff --- /dev/null +++ b/src/WorkflowStreams/BoundedLog/Scenario.cs @@ -0,0 +1,68 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Extensions.WorkflowStreams; + +public static partial class Scenarios +{ + private const int TickCount = 30; + private const int KeepLast = 5; + private const int TruncateEvery = 5; + private const long StaleOffset = 1; + + public static async Task RunTruncatingTickerAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-ticker-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (TickerWorkflow wf) => wf.RunAsync( + new TickerInput(TickCount, KeepLast, TruncateEvery, null, null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + async Task FastSubscriberAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe()) + { + var evt = Decode(client, item); + Console.WriteLine($"[fast] offset={item.Offset,3} n={evt.N}"); + if (evt.N == TickCount - 1) + { + break; + } + } + } + + async Task LateSubscriberAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var firstTruncate = ((KeepLast / TruncateEvery) + 1) * TruncateEvery; + while (await streamClient.GetOffsetAsync() <= firstTruncate) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + var first = true; + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe(StaleOffset)) + { + var evt = Decode(client, item); + if (first && item.Offset > StaleOffset) + { + Console.WriteLine( + $"[late] requested offset {StaleOffset} but it was truncated; " + + $"fast-forwarded to offset {item.Offset} " + + $"(skipped {item.Offset - StaleOffset} tick(s))"); + } + first = false; + Console.WriteLine($"[late] offset={item.Offset,3} n={evt.N}"); + if (evt.N == TickCount - 1) + { + break; + } + } + } + + await Task.WhenAll(FastSubscriberAsync(), LateSubscriberAsync()); + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } +} diff --git a/src/WorkflowStreams/TickerWorkflow.workflow.cs b/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs similarity index 100% rename from src/WorkflowStreams/TickerWorkflow.workflow.cs rename to src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs diff --git a/src/WorkflowStreams/HubWorkflow.workflow.cs b/src/WorkflowStreams/ExternalPublisher/HubWorkflow.workflow.cs similarity index 100% rename from src/WorkflowStreams/HubWorkflow.workflow.cs rename to src/WorkflowStreams/ExternalPublisher/HubWorkflow.workflow.cs diff --git a/src/WorkflowStreams/ExternalPublisher/Models.cs b/src/WorkflowStreams/ExternalPublisher/Models.cs new file mode 100644 index 0000000..2cd3459 --- /dev/null +++ b/src/WorkflowStreams/ExternalPublisher/Models.cs @@ -0,0 +1,7 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; + +public record HubInput(string HubId, WorkflowStreamState? StreamState = null); + +public record NewsEvent(string Headline); diff --git a/src/WorkflowStreams/ExternalPublisher/Scenario.cs b/src/WorkflowStreams/ExternalPublisher/Scenario.cs new file mode 100644 index 0000000..0fdd1f8 --- /dev/null +++ b/src/WorkflowStreams/ExternalPublisher/Scenario.cs @@ -0,0 +1,56 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Extensions.WorkflowStreams; + +public static partial class Scenarios +{ + private static readonly string[] Headlines = + [ + "markets open higher", + "new bridge opens downtown", + "local team wins championship", + ]; + + public static async Task RunExternalPublisherAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-hub-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (HubWorkflow wf) => wf.RunAsync(new HubInput("newsroom", null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + async Task SubscribeAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicNews).Subscribe()) + { + var evt = Decode(client, item); + if (evt.Headline == WorkflowStreamsConstants.DoneHeadline) + { + break; + } + Console.WriteLine($"[subscriber] {evt.Headline}"); + } + } + + async Task PublishAsync() + { + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var news = streamClient.Topic(WorkflowStreamsConstants.TopicNews); + foreach (var headline in Headlines) + { + news.Publish(new NewsEvent(headline)); + Console.WriteLine($"[publisher] sent: {headline}"); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + } + news.Publish(new NewsEvent(WorkflowStreamsConstants.DoneHeadline), forceFlush: true); + await streamClient.FlushAsync(); + await handle.SignalAsync(wf => wf.CloseAsync()); + Console.WriteLine("[publisher] signaled close"); + } + + await Task.WhenAll(SubscribeAsync(), PublishAsync()); + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } +} diff --git a/src/WorkflowStreams/ListenerSubscription/Scenario.cs b/src/WorkflowStreams/ListenerSubscription/Scenario.cs new file mode 100644 index 0000000..1e8c1a8 --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/Scenario.cs @@ -0,0 +1,98 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Extensions.WorkflowStreams; + +public static partial class Scenarios +{ + private static readonly string[] OrderIds = new[] { "order-A", "order-B" }; + + public static async Task RunListenerAsync(ITemporalClient client) + { + var workflowHandles = new List>(); + var streamClients = new List(); + var subscriptionHandles = new List(); + using var renderLock = new SemaphoreSlim(1, 1); + + foreach (var orderId in OrderIds) + { + var workflowId = $"workflow-streams-listener-{orderId}-{Guid.NewGuid()}"; + var workflowHandle = await client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput(orderId, null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + +#pragma warning disable CA2000 // The client is closed in the method's finally block + var streamClient = new WorkflowStreamClient(client, workflowId); +#pragma warning restore CA2000 + var subscriptionHandle = streamClient.Subscribe( + new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicStatus, + WorkflowStreamsConstants.TopicProgress, + }, + }, + new RenderingListener(client, orderId, renderLock)); + workflowHandles.Add(workflowHandle); + streamClients.Add(streamClient); + subscriptionHandles.Add(subscriptionHandle); + } + + try + { + await Task.WhenAll(subscriptionHandles.Select(handle => handle.Completion)); + for (var i = 0; i < workflowHandles.Count; i++) + { + var result = await workflowHandles[i].GetResultAsync(); + Console.WriteLine($"[{OrderIds[i]}] workflow result: {result}"); + } + } + finally + { + foreach (var handle in subscriptionHandles) + { + handle.Dispose(); + } + foreach (var streamClient in streamClients) + { + await streamClient.CloseAsync(); + } + } + } + + private sealed class RenderingListener( + ITemporalClient client, + string orderId, + SemaphoreSlim renderLock) : WorkflowStreamListener + { + public override async Task OnNextAsync(WorkflowStreamItem item) + { + await renderLock.WaitAsync().ConfigureAwait(false); + try + { + if (item.Topic == WorkflowStreamsConstants.TopicStatus) + { + Console.WriteLine( + $"[{orderId}] [status] {Decode(client, item).Kind}"); + } + else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + { + Console.WriteLine( + $"[{orderId}] [progress] {Decode(client, item).Message}"); + } + } + finally + { + renderLock.Release(); + } + } + + public override void OnCompleted() => + Console.WriteLine($"[{orderId}] stream completed"); + + public override void OnError(Exception failure) => + Console.Error.WriteLine($"[{orderId}] stream failed: {failure}"); + } +} diff --git a/src/WorkflowStreams/LlmActivities.cs b/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs similarity index 100% rename from src/WorkflowStreams/LlmActivities.cs rename to src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs diff --git a/src/WorkflowStreams/LlmWorkflow.workflow.cs b/src/WorkflowStreams/LlmTokenStreaming/LlmWorkflow.workflow.cs similarity index 100% rename from src/WorkflowStreams/LlmWorkflow.workflow.cs rename to src/WorkflowStreams/LlmTokenStreaming/LlmWorkflow.workflow.cs diff --git a/src/WorkflowStreams/LlmTokenStreaming/Models.cs b/src/WorkflowStreams/LlmTokenStreaming/Models.cs new file mode 100644 index 0000000..baf57e9 --- /dev/null +++ b/src/WorkflowStreams/LlmTokenStreaming/Models.cs @@ -0,0 +1,14 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; + +public record LlmInput( + string Prompt, + string Model = "gpt-4o-mini", + WorkflowStreamState? StreamState = null); + +public record TextDelta(string Text); + +public record TextComplete(string FullText); + +public record RetryEvent(int Attempt); diff --git a/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs b/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs new file mode 100644 index 0000000..3c7f77b --- /dev/null +++ b/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs @@ -0,0 +1,62 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Extensions.WorkflowStreams; + +public static partial class Scenarios +{ + public static async Task RunLlmAsync(ITemporalClient client, string? prompt) + { + const string ansiSave = "\u001b[s"; + const string ansiRestoreAndClear = "\u001b[u\u001b[J"; + prompt ??= + "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 practical implementation tradeoffs."; + + var input = new LlmInput(prompt); + var workflowId = $"workflow-streams-llm-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (LlmWorkflow wf) => wf.RunAsync(input), + new(workflowId, WorkflowStreamsConstants.LlmTaskQueue)); + Console.WriteLine( + $"[llm {workflowId}] streaming response from {input.Model}, awaiting first token..."); + Console.WriteLine(); + Console.Write(ansiSave); + + await using var streamClient = new WorkflowStreamClient(client, workflowId); + var options = new SubscribeOptions + { + Topics = new List + { + WorkflowStreamsConstants.TopicDelta, + WorkflowStreamsConstants.TopicRetry, + WorkflowStreamsConstants.TopicComplete, + }, + }; + await foreach (var item in streamClient.Subscribe(options)) + { + if (item.Topic == WorkflowStreamsConstants.TopicRetry) + { + var evt = Decode(client, item); + Console.Write(ansiRestoreAndClear); + Console.WriteLine($"[retry attempt {evt.Attempt}] resetting output"); + Console.WriteLine(); + Console.Write(ansiSave); + } + else if (item.Topic == WorkflowStreamsConstants.TopicDelta) + { + Console.Write(Decode(client, item).Text); + } + else if (item.Topic == WorkflowStreamsConstants.TopicComplete) + { + _ = Decode(client, item); + Console.WriteLine(); + break; + } + } + + var result = await handle.GetResultAsync(); + Console.WriteLine($"[workflow result: {result.Length} chars]"); + } +} diff --git a/src/WorkflowStreams/README.md b/src/WorkflowStreams/README.md index 7600388..1c2db4c 100644 --- a/src/WorkflowStreams/README.md +++ b/src/WorkflowStreams/README.md @@ -8,8 +8,17 @@ Workflow. Workflow code and external clients publish to named topics through Sig long-poll through Updates, and a Query exposes the current global offset. The extension handles batching, publisher deduplication, topic filtering, Continue-As-New handoff, and truncation. -The sample contains six scenarios. The first five commands use the shared worker; LLM streaming -uses a separate worker and task queue because it requires an OpenAI API key. +The sample contains six independently linkable scenarios: + +- [Basic publish/subscribe](BasicPublishSubscribe) +- [Listener subscription](ListenerSubscription) +- [Reconnecting subscriber](ReconnectingSubscriber) +- [External publisher](ExternalPublisher) +- [Bounded log](BoundedLog) +- [LLM token streaming](LlmTokenStreaming) + +The first five commands use the shared worker; LLM streaming uses a separate worker and task queue +because it requires an OpenAI API key. ## Run the sample @@ -32,13 +41,13 @@ dotnet run -- external-publisher # Publish from code outside a Workflow or Acti dotnet run -- ticker # Bound the log by truncating old entries ``` -### Basic publish/subscribe +### [Basic publish/subscribe](BasicPublishSubscribe) `OrderWorkflow` publishes lifecycle events to the `status` topic. Its payment Activity creates a client with `WorkflowStreamClient.FromActivity()` and publishes finer-grained events to `progress`. The subscriber consumes both topics with `await foreach` and decodes each payload by topic. -### Listener subscription +### [Listener subscription](ListenerSubscription) The listener scenario starts two order Workflows and subscribes to each with `WorkflowStreamListener`. `Subscribe` returns immediately with a @@ -47,24 +56,24 @@ returned by `OnNextAsync` prevents the next item and poll from being delivered u finishes. Unlike a blocking iterator, both this API and .NET's `await foreach` API poll fully asynchronously without occupying a thread. -### Reconnecting subscriber +### [Reconnecting subscriber](ReconnectingSubscriber) The first subscriber reads two pipeline stages and saves one past the last global offset it saw. A fresh client then calls `Subscribe(savedOffset)` and receives the remaining stages without gaps or duplicates. Offsets are global across topics, not per-topic. -### External publisher +### [External publisher](ExternalPublisher) `HubWorkflow` only hosts the stream. A normal client publishes news into it while another client subscribes, then signals the Workflow to close after flushing a sentinel event. -### Bounded log +### [Bounded log](BoundedLog) `TickerWorkflow` periodically calls `Truncate()` to retain only recent events. A fast subscriber sees every tick. A late subscriber requests an offset that has already been truncated and is automatically advanced to the retained base offset, demonstrating the bounded-history tradeoff. -## LLM token streaming +## [LLM token streaming](LlmTokenStreaming) Set `OPENAI_API_KEY` and start the dedicated worker: diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Models.cs b/src/WorkflowStreams/ReconnectingSubscriber/Models.cs new file mode 100644 index 0000000..a9a2334 --- /dev/null +++ b/src/WorkflowStreams/ReconnectingSubscriber/Models.cs @@ -0,0 +1,10 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Extensions.WorkflowStreams; + +public record PipelineInput( + string PipelineId, + TimeSpan? StageInterval = null, + WorkflowStreamState? StreamState = null); + +public record StageEvent(string Stage); diff --git a/src/WorkflowStreams/PipelineWorkflow.workflow.cs b/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs similarity index 100% rename from src/WorkflowStreams/PipelineWorkflow.workflow.cs rename to src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs new file mode 100644 index 0000000..74f38d1 --- /dev/null +++ b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs @@ -0,0 +1,50 @@ +namespace TemporalioSamples.WorkflowStreams; + +using Temporalio.Client; +using Temporalio.Extensions.WorkflowStreams; + +public static partial class Scenarios +{ + public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) + { + var workflowId = $"workflow-streams-pipeline-{Guid.NewGuid()}"; + var handle = await client.StartWorkflowAsync( + (PipelineWorkflow wf) => wf.RunAsync(new PipelineInput("pipeline-7", null, null)), + new(workflowId, WorkflowStreamsConstants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + + long nextOffset = 0; + Console.WriteLine("--- phase 1: initial subscriber ---"); + await using (var streamClient = new WorkflowStreamClient(client, workflowId)) + { + var seen = 0; + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe()) + { + var evt = Decode(client, item); + nextOffset = item.Offset + 1; + Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); + if (++seen == 2) + { + break; + } + } + } + + Console.WriteLine($"--- disconnected; will resume from offset {nextOffset} ---"); + Console.WriteLine("--- phase 2: reconnected subscriber ---"); + await using (var streamClient = new WorkflowStreamClient(client, workflowId)) + { + await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe(nextOffset)) + { + var evt = Decode(client, item); + Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); + if (evt.Stage == "complete") + { + break; + } + } + } + + Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); + } +} diff --git a/src/WorkflowStreams/Scenarios.cs b/src/WorkflowStreams/Scenarios.cs index 0fb4906..526ee7c 100644 --- a/src/WorkflowStreams/Scenarios.cs +++ b/src/WorkflowStreams/Scenarios.cs @@ -4,346 +4,8 @@ namespace TemporalioSamples.WorkflowStreams; using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static class Scenarios +public static partial class Scenarios { - private const int TickCount = 30; - private const int KeepLast = 5; - private const int TruncateEvery = 5; - private const long StaleOffset = 1; - - private static readonly string[] Headlines = - [ - "markets open higher", - "new bridge opens downtown", - "local team wins championship", - ]; - - private static readonly string[] OrderIds = new[] { "order-A", "order-B" }; - - public static async Task RunPublisherAsync(ITemporalClient client) - { - var workflowId = $"workflow-streams-order-{Guid.NewGuid()}"; - var handle = await client.StartWorkflowAsync( - (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-42", null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); - Console.WriteLine($"Started workflow: {workflowId}"); - - await using var streamClient = new WorkflowStreamClient(client, workflowId); - var options = new SubscribeOptions - { - Topics = new List - { - WorkflowStreamsConstants.TopicStatus, - WorkflowStreamsConstants.TopicProgress, - }, - }; - await foreach (var item in streamClient.Subscribe(options)) - { - if (item.Topic == WorkflowStreamsConstants.TopicStatus) - { - var evt = Decode(client, item); - Console.WriteLine($"[status] {evt.Kind}: order={evt.OrderId}"); - if (evt.Kind == "complete") - { - break; - } - } - else if (item.Topic == WorkflowStreamsConstants.TopicProgress) - { - var evt = Decode(client, item); - Console.WriteLine($"[progress] {evt.Message}"); - } - } - - Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); - } - - public static async Task RunListenerAsync(ITemporalClient client) - { - var workflowHandles = new List>(); - var streamClients = new List(); - var subscriptionHandles = new List(); - using var renderLock = new SemaphoreSlim(1, 1); - - foreach (var orderId in OrderIds) - { - var workflowId = $"workflow-streams-listener-{orderId}-{Guid.NewGuid()}"; - var workflowHandle = await client.StartWorkflowAsync( - (OrderWorkflow wf) => wf.RunAsync(new OrderInput(orderId, null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); - Console.WriteLine($"Started workflow: {workflowId}"); - -#pragma warning disable CA2000 // The client is closed in the method's finally block - var streamClient = new WorkflowStreamClient(client, workflowId); -#pragma warning restore CA2000 - var listener = new RenderingListener(client, orderId, renderLock); - var subscriptionHandle = streamClient.Subscribe( - new SubscribeOptions - { - Topics = new List - { - WorkflowStreamsConstants.TopicStatus, - WorkflowStreamsConstants.TopicProgress, - }, - }, - listener); - workflowHandles.Add(workflowHandle); - streamClients.Add(streamClient); - subscriptionHandles.Add(subscriptionHandle); - } - - try - { - await Task.WhenAll(subscriptionHandles.Select(handle => handle.Completion)); - for (var i = 0; i < workflowHandles.Count; i++) - { - var result = await workflowHandles[i].GetResultAsync(); - Console.WriteLine($"[{OrderIds[i]}] workflow result: {result}"); - } - } - finally - { - foreach (var handle in subscriptionHandles) - { - handle.Dispose(); - } - foreach (var streamClient in streamClients) - { - await streamClient.CloseAsync(); - } - } - } - - public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) - { - var workflowId = $"workflow-streams-pipeline-{Guid.NewGuid()}"; - var handle = await client.StartWorkflowAsync( - (PipelineWorkflow wf) => wf.RunAsync(new PipelineInput("pipeline-7", null, null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); - Console.WriteLine($"Started workflow: {workflowId}"); - - long nextOffset = 0; - Console.WriteLine("--- phase 1: initial subscriber ---"); - await using (var streamClient = new WorkflowStreamClient(client, workflowId)) - { - var seen = 0; - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe()) - { - var evt = Decode(client, item); - nextOffset = item.Offset + 1; - Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); - if (++seen == 2) - { - break; - } - } - } - - Console.WriteLine($"--- disconnected; will resume from offset {nextOffset} ---"); - Console.WriteLine("--- phase 2: reconnected subscriber ---"); - await using (var streamClient = new WorkflowStreamClient(client, workflowId)) - { - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe(nextOffset)) - { - var evt = Decode(client, item); - Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); - if (evt.Stage == "complete") - { - break; - } - } - } - - Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); - } - - public static async Task RunExternalPublisherAsync(ITemporalClient client) - { - var workflowId = $"workflow-streams-hub-{Guid.NewGuid()}"; - var handle = await client.StartWorkflowAsync( - (HubWorkflow wf) => wf.RunAsync(new HubInput("newsroom", null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); - Console.WriteLine($"Started workflow: {workflowId}"); - - async Task SubscribeAsync() - { - await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicNews).Subscribe()) - { - var evt = Decode(client, item); - if (evt.Headline == WorkflowStreamsConstants.DoneHeadline) - { - break; - } - Console.WriteLine($"[subscriber] {evt.Headline}"); - } - } - - async Task PublishAsync() - { - await using var streamClient = new WorkflowStreamClient(client, workflowId); - var news = streamClient.Topic(WorkflowStreamsConstants.TopicNews); - foreach (var headline in Headlines) - { - news.Publish(new NewsEvent(headline)); - Console.WriteLine($"[publisher] sent: {headline}"); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - } - news.Publish(new NewsEvent(WorkflowStreamsConstants.DoneHeadline), forceFlush: true); - await streamClient.FlushAsync(); - await handle.SignalAsync(wf => wf.CloseAsync()); - Console.WriteLine("[publisher] signaled close"); - } - - await Task.WhenAll(SubscribeAsync(), PublishAsync()); - Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); - } - - public static async Task RunTruncatingTickerAsync(ITemporalClient client) - { - var workflowId = $"workflow-streams-ticker-{Guid.NewGuid()}"; - var handle = await client.StartWorkflowAsync( - (TickerWorkflow wf) => wf.RunAsync( - new TickerInput(TickCount, KeepLast, TruncateEvery, null, null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); - Console.WriteLine($"Started workflow: {workflowId}"); - - async Task FastSubscriberAsync() - { - await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe()) - { - var evt = Decode(client, item); - Console.WriteLine($"[fast] offset={item.Offset,3} n={evt.N}"); - if (evt.N == TickCount - 1) - { - break; - } - } - } - - async Task LateSubscriberAsync() - { - await using var streamClient = new WorkflowStreamClient(client, workflowId); - var firstTruncate = ((KeepLast / TruncateEvery) + 1) * TruncateEvery; - while (await streamClient.GetOffsetAsync() <= firstTruncate) - { - await Task.Delay(TimeSpan.FromMilliseconds(200)); - } - - var first = true; - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe(StaleOffset)) - { - var evt = Decode(client, item); - if (first && item.Offset > StaleOffset) - { - Console.WriteLine( - $"[late] requested offset {StaleOffset} but it was truncated; " + - $"fast-forwarded to offset {item.Offset} " + - $"(skipped {item.Offset - StaleOffset} tick(s))"); - } - first = false; - Console.WriteLine($"[late] offset={item.Offset,3} n={evt.N}"); - if (evt.N == TickCount - 1) - { - break; - } - } - } - - await Task.WhenAll(FastSubscriberAsync(), LateSubscriberAsync()); - Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); - } - - public static async Task RunLlmAsync(ITemporalClient client, string? prompt) - { - const string ansiSave = "\u001b[s"; - const string ansiRestoreAndClear = "\u001b[u\u001b[J"; - prompt ??= - "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 practical implementation tradeoffs."; - - var input = new LlmInput(prompt); - var workflowId = $"workflow-streams-llm-{Guid.NewGuid()}"; - var handle = await client.StartWorkflowAsync( - (LlmWorkflow wf) => wf.RunAsync(input), - new(workflowId, WorkflowStreamsConstants.LlmTaskQueue)); - Console.WriteLine( - $"[llm {workflowId}] streaming response from {input.Model}, awaiting first token..."); - Console.WriteLine(); - Console.Write(ansiSave); - - await using var streamClient = new WorkflowStreamClient(client, workflowId); - var options = new SubscribeOptions - { - Topics = new List - { - WorkflowStreamsConstants.TopicDelta, - WorkflowStreamsConstants.TopicRetry, - WorkflowStreamsConstants.TopicComplete, - }, - }; - await foreach (var item in streamClient.Subscribe(options)) - { - if (item.Topic == WorkflowStreamsConstants.TopicRetry) - { - var evt = Decode(client, item); - Console.Write(ansiRestoreAndClear); - Console.WriteLine($"[retry attempt {evt.Attempt}] resetting output"); - Console.WriteLine(); - Console.Write(ansiSave); - } - else if (item.Topic == WorkflowStreamsConstants.TopicDelta) - { - Console.Write(Decode(client, item).Text); - } - else if (item.Topic == WorkflowStreamsConstants.TopicComplete) - { - _ = Decode(client, item); - Console.WriteLine(); - break; - } - } - - var result = await handle.GetResultAsync(); - Console.WriteLine($"[workflow result: {result.Length} chars]"); - } - private static T Decode(ITemporalClient client, WorkflowStreamItem item) => client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); - - private sealed class RenderingListener( - ITemporalClient client, - string orderId, - SemaphoreSlim renderLock) : WorkflowStreamListener - { - public override async Task OnNextAsync(WorkflowStreamItem item) - { - await renderLock.WaitAsync().ConfigureAwait(false); - try - { - if (item.Topic == WorkflowStreamsConstants.TopicStatus) - { - Console.WriteLine( - $"[{orderId}] [status] {Decode(client, item).Kind}"); - } - else if (item.Topic == WorkflowStreamsConstants.TopicProgress) - { - Console.WriteLine( - $"[{orderId}] [progress] {Decode(client, item).Message}"); - } - } - finally - { - renderLock.Release(); - } - } - - public override void OnCompleted() => - Console.WriteLine($"[{orderId}] stream completed"); - - public override void OnError(Exception failure) => - Console.Error.WriteLine($"[{orderId}] stream failed: {failure}"); - } } diff --git a/src/WorkflowStreams/Shared.cs b/src/WorkflowStreams/Shared.cs index 42a1f43..74f303a 100644 --- a/src/WorkflowStreams/Shared.cs +++ b/src/WorkflowStreams/Shared.cs @@ -1,7 +1,5 @@ namespace TemporalioSamples.WorkflowStreams; -using Temporalio.Extensions.WorkflowStreams; - public static class WorkflowStreamsConstants { public const string TaskQueue = "workflow-streams"; @@ -26,40 +24,3 @@ public static class WorkflowStreamsConstants public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); } - -public record OrderInput(string OrderId, WorkflowStreamState? StreamState = null); - -public record PipelineInput( - string PipelineId, - TimeSpan? StageInterval = null, - WorkflowStreamState? StreamState = null); - -public record HubInput(string HubId, WorkflowStreamState? StreamState = null); - -public record TickerInput( - int Count = 50, - int KeepLast = 10, - int TruncateEvery = 5, - TimeSpan? Interval = null, - WorkflowStreamState? StreamState = null); - -public record LlmInput( - string Prompt, - string Model = "gpt-4o-mini", - WorkflowStreamState? StreamState = null); - -public record StatusEvent(string Kind, string OrderId); - -public record ProgressEvent(string Message); - -public record StageEvent(string Stage); - -public record NewsEvent(string Headline); - -public record TickEvent(int N); - -public record TextDelta(string Text); - -public record TextComplete(string FullText); - -public record RetryEvent(int Attempt); From 8cbfad0185a0b856cc078d2a6c3a7847f5e50cdd Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 24 Aug 2026 17:09:29 -0700 Subject: [PATCH 4/6] Make workflow stream samples self-contained --- TemporalioSamples.sln | 77 ++++++++++++++++- .../BasicPublishSubscribe/Constants.cs | 12 +++ .../BasicPublishSubscribe/Models.cs | 2 +- .../OrderWorkflow.workflow.cs | 8 +- .../PaymentActivities.cs | 4 +- .../BasicPublishSubscribe/Program.cs | 33 +++++++ .../BasicPublishSubscribe/README.md | 16 ++++ .../BasicPublishSubscribe/Scenario.cs | 18 ++-- ...rkflowStreams.BasicPublishSubscribe.csproj | 11 +++ src/WorkflowStreams/BoundedLog/Constants.cs | 10 +++ src/WorkflowStreams/BoundedLog/Models.cs | 2 +- src/WorkflowStreams/BoundedLog/Program.cs | 31 +++++++ src/WorkflowStreams/BoundedLog/README.md | 16 ++++ src/WorkflowStreams/BoundedLog/Scenario.cs | 14 +-- ...oSamples.WorkflowStreams.BoundedLog.csproj | 11 +++ .../BoundedLog/TickerWorkflow.workflow.cs | 6 +- .../ExternalPublisher/Constants.cs | 12 +++ .../ExternalPublisher/HubWorkflow.workflow.cs | 4 +- .../ExternalPublisher/Models.cs | 2 +- .../ExternalPublisher/Program.cs | 31 +++++++ .../ExternalPublisher/README.md | 16 ++++ .../ExternalPublisher/Scenario.cs | 18 ++-- ...s.WorkflowStreams.ExternalPublisher.csproj | 11 +++ .../ListenerSubscription/Constants.cs | 12 +++ .../ListenerSubscription/Models.cs | 9 ++ .../OrderWorkflow.workflow.cs | 31 +++++++ .../ListenerSubscription/PaymentActivities.cs | 26 ++++++ .../ListenerSubscription/Program.cs | 33 +++++++ .../ListenerSubscription/README.md | 16 ++++ .../ListenerSubscription/Scenario.cs | 18 ++-- ...orkflowStreams.ListenerSubscription.csproj | 11 +++ .../LlmTokenStreaming/Constants.cs | 14 +++ .../LlmTokenStreaming/LlmActivities.cs | 8 +- .../LlmTokenStreaming/LlmWorkflow.workflow.cs | 4 +- .../LlmTokenStreaming/Models.cs | 2 +- .../LlmTokenStreaming/Program.cs | 35 ++++++++ .../LlmTokenStreaming/README.md | 18 ++++ .../LlmTokenStreaming/Scenario.cs | 22 +++-- ....WorkflowStreams.LlmTokenStreaming.csproj} | 0 src/WorkflowStreams/Program.cs | 86 ------------------- src/WorkflowStreams/README.md | 83 +----------------- .../ReconnectingSubscriber/Constants.cs | 10 +++ .../ReconnectingSubscriber/Models.cs | 2 +- .../PipelineWorkflow.workflow.cs | 6 +- .../ReconnectingSubscriber/Program.cs | 31 +++++++ .../ReconnectingSubscriber/README.md | 16 ++++ .../ReconnectingSubscriber/Scenario.cs | 14 +-- ...kflowStreams.ReconnectingSubscriber.csproj | 11 +++ src/WorkflowStreams/Scenarios.cs | 11 --- src/WorkflowStreams/Shared.cs | 26 ------ tests/TemporalioSamples.Tests.csproj | 7 +- tests/WorkflowStreams/WorkflowStreamsTests.cs | 77 +++++++++-------- 52 files changed, 701 insertions(+), 303 deletions(-) create mode 100644 src/WorkflowStreams/BasicPublishSubscribe/Constants.cs create mode 100644 src/WorkflowStreams/BasicPublishSubscribe/Program.cs create mode 100644 src/WorkflowStreams/BasicPublishSubscribe/README.md create mode 100644 src/WorkflowStreams/BasicPublishSubscribe/TemporalioSamples.WorkflowStreams.BasicPublishSubscribe.csproj create mode 100644 src/WorkflowStreams/BoundedLog/Constants.cs create mode 100644 src/WorkflowStreams/BoundedLog/Program.cs create mode 100644 src/WorkflowStreams/BoundedLog/README.md create mode 100644 src/WorkflowStreams/BoundedLog/TemporalioSamples.WorkflowStreams.BoundedLog.csproj create mode 100644 src/WorkflowStreams/ExternalPublisher/Constants.cs create mode 100644 src/WorkflowStreams/ExternalPublisher/Program.cs create mode 100644 src/WorkflowStreams/ExternalPublisher/README.md create mode 100644 src/WorkflowStreams/ExternalPublisher/TemporalioSamples.WorkflowStreams.ExternalPublisher.csproj create mode 100644 src/WorkflowStreams/ListenerSubscription/Constants.cs create mode 100644 src/WorkflowStreams/ListenerSubscription/Models.cs create mode 100644 src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs create mode 100644 src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs create mode 100644 src/WorkflowStreams/ListenerSubscription/Program.cs create mode 100644 src/WorkflowStreams/ListenerSubscription/README.md create mode 100644 src/WorkflowStreams/ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj create mode 100644 src/WorkflowStreams/LlmTokenStreaming/Constants.cs create mode 100644 src/WorkflowStreams/LlmTokenStreaming/Program.cs create mode 100644 src/WorkflowStreams/LlmTokenStreaming/README.md rename src/WorkflowStreams/{TemporalioSamples.WorkflowStreams.csproj => LlmTokenStreaming/TemporalioSamples.WorkflowStreams.LlmTokenStreaming.csproj} (100%) delete mode 100644 src/WorkflowStreams/Program.cs create mode 100644 src/WorkflowStreams/ReconnectingSubscriber/Constants.cs create mode 100644 src/WorkflowStreams/ReconnectingSubscriber/Program.cs create mode 100644 src/WorkflowStreams/ReconnectingSubscriber/README.md create mode 100644 src/WorkflowStreams/ReconnectingSubscriber/TemporalioSamples.WorkflowStreams.ReconnectingSubscriber.csproj delete mode 100644 src/WorkflowStreams/Scenarios.cs delete mode 100644 src/WorkflowStreams/Shared.cs diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index d63e7d2..715cb94 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -141,7 +141,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SearchAttributes", "SearchA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SearchAttributes", "src\SearchAttributes\TemporalioSamples.SearchAttributes.csproj", "{97376F57-BA10-464B-AFBD-583E187DE947}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams", "src\WorkflowStreams\TemporalioSamples.WorkflowStreams.csproj", "{091DC9D4-A88D-4393-9F84-32627DB1141F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.BasicPublishSubscribe", "src\WorkflowStreams\BasicPublishSubscribe\TemporalioSamples.WorkflowStreams.BasicPublishSubscribe.csproj", "{091DC9D4-A88D-4393-9F84-32627DB1141F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.BoundedLog", "src\WorkflowStreams\BoundedLog\TemporalioSamples.WorkflowStreams.BoundedLog.csproj", "{A1111111-1111-4111-8111-111111111111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.ExternalPublisher", "src\WorkflowStreams\ExternalPublisher\TemporalioSamples.WorkflowStreams.ExternalPublisher.csproj", "{B2222222-2222-4222-8222-222222222222}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.ListenerSubscription", "src\WorkflowStreams\ListenerSubscription\TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj", "{C3333333-3333-4333-8333-333333333333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.LlmTokenStreaming", "src\WorkflowStreams\LlmTokenStreaming\TemporalioSamples.WorkflowStreams.LlmTokenStreaming.csproj", "{D4444444-4444-4444-8444-444444444444}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.ReconnectingSubscriber", "src\WorkflowStreams\ReconnectingSubscriber\TemporalioSamples.WorkflowStreams.ReconnectingSubscriber.csproj", "{E5555555-5555-4555-8555-555555555555}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -813,6 +823,66 @@ Global {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x64.Build.0 = Release|Any CPU {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x86.ActiveCfg = Release|Any CPU {091DC9D4-A88D-4393-9F84-32627DB1141F}.Release|x86.Build.0 = Release|Any CPU + {A1111111-1111-4111-8111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-4111-8111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-4111-8111-111111111111}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1111111-1111-4111-8111-111111111111}.Debug|x64.Build.0 = Debug|Any CPU + {A1111111-1111-4111-8111-111111111111}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1111111-1111-4111-8111-111111111111}.Debug|x86.Build.0 = Debug|Any CPU + {A1111111-1111-4111-8111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-4111-8111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-4111-8111-111111111111}.Release|x64.ActiveCfg = Release|Any CPU + {A1111111-1111-4111-8111-111111111111}.Release|x64.Build.0 = Release|Any CPU + {A1111111-1111-4111-8111-111111111111}.Release|x86.ActiveCfg = Release|Any CPU + {A1111111-1111-4111-8111-111111111111}.Release|x86.Build.0 = Release|Any CPU + {B2222222-2222-4222-8222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-4222-8222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-4222-8222-222222222222}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2222222-2222-4222-8222-222222222222}.Debug|x64.Build.0 = Debug|Any CPU + {B2222222-2222-4222-8222-222222222222}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2222222-2222-4222-8222-222222222222}.Debug|x86.Build.0 = Debug|Any CPU + {B2222222-2222-4222-8222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-4222-8222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-4222-8222-222222222222}.Release|x64.ActiveCfg = Release|Any CPU + {B2222222-2222-4222-8222-222222222222}.Release|x64.Build.0 = Release|Any CPU + {B2222222-2222-4222-8222-222222222222}.Release|x86.ActiveCfg = Release|Any CPU + {B2222222-2222-4222-8222-222222222222}.Release|x86.Build.0 = Release|Any CPU + {C3333333-3333-4333-8333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3333333-3333-4333-8333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3333333-3333-4333-8333-333333333333}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3333333-3333-4333-8333-333333333333}.Debug|x64.Build.0 = Debug|Any CPU + {C3333333-3333-4333-8333-333333333333}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3333333-3333-4333-8333-333333333333}.Debug|x86.Build.0 = Debug|Any CPU + {C3333333-3333-4333-8333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3333333-3333-4333-8333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {C3333333-3333-4333-8333-333333333333}.Release|x64.ActiveCfg = Release|Any CPU + {C3333333-3333-4333-8333-333333333333}.Release|x64.Build.0 = Release|Any CPU + {C3333333-3333-4333-8333-333333333333}.Release|x86.ActiveCfg = Release|Any CPU + {C3333333-3333-4333-8333-333333333333}.Release|x86.Build.0 = Release|Any CPU + {D4444444-4444-4444-8444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4444444-4444-4444-8444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4444444-4444-4444-8444-444444444444}.Debug|x64.ActiveCfg = Debug|Any CPU + {D4444444-4444-4444-8444-444444444444}.Debug|x64.Build.0 = Debug|Any CPU + {D4444444-4444-4444-8444-444444444444}.Debug|x86.ActiveCfg = Debug|Any CPU + {D4444444-4444-4444-8444-444444444444}.Debug|x86.Build.0 = Debug|Any CPU + {D4444444-4444-4444-8444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4444444-4444-4444-8444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU + {D4444444-4444-4444-8444-444444444444}.Release|x64.ActiveCfg = Release|Any CPU + {D4444444-4444-4444-8444-444444444444}.Release|x64.Build.0 = Release|Any CPU + {D4444444-4444-4444-8444-444444444444}.Release|x86.ActiveCfg = Release|Any CPU + {D4444444-4444-4444-8444-444444444444}.Release|x86.Build.0 = Release|Any CPU + {E5555555-5555-4555-8555-555555555555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5555555-5555-4555-8555-555555555555}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5555555-5555-4555-8555-555555555555}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5555555-5555-4555-8555-555555555555}.Debug|x64.Build.0 = Debug|Any CPU + {E5555555-5555-4555-8555-555555555555}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5555555-5555-4555-8555-555555555555}.Debug|x86.Build.0 = Debug|Any CPU + {E5555555-5555-4555-8555-555555555555}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5555555-5555-4555-8555-555555555555}.Release|Any CPU.Build.0 = Release|Any CPU + {E5555555-5555-4555-8555-555555555555}.Release|x64.ActiveCfg = Release|Any CPU + {E5555555-5555-4555-8555-555555555555}.Release|x64.Build.0 = Release|Any CPU + {E5555555-5555-4555-8555-555555555555}.Release|x86.ActiveCfg = Release|Any CPU + {E5555555-5555-4555-8555-555555555555}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -884,5 +954,10 @@ Global {34ADAC00-5559-6993-F779-D71CE5A0F93F} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {97376F57-BA10-464B-AFBD-583E187DE947} = {34ADAC00-5559-6993-F779-D71CE5A0F93F} {091DC9D4-A88D-4393-9F84-32627DB1141F} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {A1111111-1111-4111-8111-111111111111} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {B2222222-2222-4222-8222-222222222222} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {C3333333-3333-4333-8333-333333333333} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {D4444444-4444-4444-8444-444444444444} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {E5555555-5555-4555-8555-555555555555} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} EndGlobalSection EndGlobal diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Constants.cs b/src/WorkflowStreams/BasicPublishSubscribe/Constants.cs new file mode 100644 index 0000000..1d35391 --- /dev/null +++ b/src/WorkflowStreams/BasicPublishSubscribe/Constants.cs @@ -0,0 +1,12 @@ +namespace TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; + +public static class Constants +{ + public const string TaskQueue = "workflow-streams-basic-publish-subscribe"; + + public const string TopicStatus = "status"; + + public const string TopicProgress = "progress"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Models.cs b/src/WorkflowStreams/BasicPublishSubscribe/Models.cs index 5501480..cba780f 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/Models.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/Models.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; using Temporalio.Extensions.WorkflowStreams; diff --git a/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs b/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs index 68bf23f..e12b4a1 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Workflows; @@ -14,8 +14,8 @@ public class OrderWorkflow [WorkflowRun] public async Task RunAsync(OrderInput input) { - var status = stream.Topic(WorkflowStreamsConstants.TopicStatus); - var progress = stream.Topic(WorkflowStreamsConstants.TopicProgress); + var status = stream.Topic(Constants.TopicStatus); + var progress = stream.Topic(Constants.TopicProgress); status.Publish(new StatusEvent("received", input.OrderId)); var chargeId = await Workflow.ExecuteActivityAsync( @@ -25,7 +25,7 @@ public async Task RunAsync(OrderInput input) progress.Publish(new ProgressEvent($"charge id: {chargeId}")); status.Publish(new StatusEvent("complete", input.OrderId)); - await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + await Workflow.DelayAsync(Constants.DrainDelay); return chargeId; } } diff --git a/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs b/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs index 67ba852..50a7c79 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; using Microsoft.Extensions.Logging; using Temporalio.Activities; @@ -11,7 +11,7 @@ public static async Task ChargeCardAsync(string orderId) { await using var streamClient = WorkflowStreamClient.FromActivity( new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); - var progress = streamClient.Topic(WorkflowStreamsConstants.TopicProgress); + var progress = streamClient.Topic(Constants.TopicProgress); progress.Publish(new ProgressEvent("charging card...")); ActivityExecutionContext.Current.Logger.LogInformation( diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Program.cs b/src/WorkflowStreams/BasicPublishSubscribe/Program.cs new file mode 100644 index 0000000..da133e3 --- /dev/null +++ b/src/WorkflowStreams/BasicPublishSubscribe/Program.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +if (args.ElementAtOrDefault(0) == "worker") +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(Constants.TaskQueue). + AddActivity(PaymentActivities.ChargeCardAsync). + AddWorkflow()); + await worker.ExecuteAsync(tokenSource.Token); +} +else +{ + await Scenario.RunPublisherAsync(client); +} diff --git a/src/WorkflowStreams/BasicPublishSubscribe/README.md b/src/WorkflowStreams/BasicPublishSubscribe/README.md new file mode 100644 index 0000000..6f2c575 --- /dev/null +++ b/src/WorkflowStreams/BasicPublishSubscribe/README.md @@ -0,0 +1,16 @@ +# Basic publish/subscribe + +An order Workflow publishes lifecycle events to `status`, while its payment Activity publishes +progress events to `progress`. The subscriber consumes and decodes both topics. + +Start a Temporal service, then run the worker: + +```bash +dotnet run -- worker +``` + +In another terminal, run the subscriber: + +```bash +dotnet run +``` diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs b/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs index 05bd8d4..70cfe33 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs @@ -1,16 +1,17 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; using Temporalio.Client; +using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static partial class Scenarios +public static class Scenario { public static async Task RunPublisherAsync(ITemporalClient client) { var workflowId = $"workflow-streams-order-{Guid.NewGuid()}"; var handle = await client.StartWorkflowAsync( (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-42", null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); + new(workflowId, Constants.TaskQueue)); Console.WriteLine($"Started workflow: {workflowId}"); await using var streamClient = new WorkflowStreamClient(client, workflowId); @@ -18,13 +19,13 @@ public static async Task RunPublisherAsync(ITemporalClient client) { Topics = new List { - WorkflowStreamsConstants.TopicStatus, - WorkflowStreamsConstants.TopicProgress, + Constants.TopicStatus, + Constants.TopicProgress, }, }; await foreach (var item in streamClient.Subscribe(options)) { - if (item.Topic == WorkflowStreamsConstants.TopicStatus) + if (item.Topic == Constants.TopicStatus) { var evt = Decode(client, item); Console.WriteLine($"[status] {evt.Kind}: order={evt.OrderId}"); @@ -33,7 +34,7 @@ public static async Task RunPublisherAsync(ITemporalClient client) break; } } - else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + else if (item.Topic == Constants.TopicProgress) { var evt = Decode(client, item); Console.WriteLine($"[progress] {evt.Message}"); @@ -42,4 +43,7 @@ public static async Task RunPublisherAsync(ITemporalClient client) Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/BasicPublishSubscribe/TemporalioSamples.WorkflowStreams.BasicPublishSubscribe.csproj b/src/WorkflowStreams/BasicPublishSubscribe/TemporalioSamples.WorkflowStreams.BasicPublishSubscribe.csproj new file mode 100644 index 0000000..c1cc2f4 --- /dev/null +++ b/src/WorkflowStreams/BasicPublishSubscribe/TemporalioSamples.WorkflowStreams.BasicPublishSubscribe.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/src/WorkflowStreams/BoundedLog/Constants.cs b/src/WorkflowStreams/BoundedLog/Constants.cs new file mode 100644 index 0000000..5cc4cac --- /dev/null +++ b/src/WorkflowStreams/BoundedLog/Constants.cs @@ -0,0 +1,10 @@ +namespace TemporalioSamples.WorkflowStreams.BoundedLog; + +public static class Constants +{ + public const string TaskQueue = "workflow-streams-bounded-log"; + + public const string TopicTick = "tick"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/src/WorkflowStreams/BoundedLog/Models.cs b/src/WorkflowStreams/BoundedLog/Models.cs index c0172c9..42a38c2 100644 --- a/src/WorkflowStreams/BoundedLog/Models.cs +++ b/src/WorkflowStreams/BoundedLog/Models.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BoundedLog; using Temporalio.Extensions.WorkflowStreams; diff --git a/src/WorkflowStreams/BoundedLog/Program.cs b/src/WorkflowStreams/BoundedLog/Program.cs new file mode 100644 index 0000000..a872a70 --- /dev/null +++ b/src/WorkflowStreams/BoundedLog/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams.BoundedLog; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +if (args.ElementAtOrDefault(0) == "worker") +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(Constants.TaskQueue).AddWorkflow()); + await worker.ExecuteAsync(tokenSource.Token); +} +else +{ + await Scenario.RunTruncatingTickerAsync(client); +} diff --git a/src/WorkflowStreams/BoundedLog/README.md b/src/WorkflowStreams/BoundedLog/README.md new file mode 100644 index 0000000..47d0276 --- /dev/null +++ b/src/WorkflowStreams/BoundedLog/README.md @@ -0,0 +1,16 @@ +# Bounded log + +A ticker Workflow periodically truncates its stream. A fast subscriber sees every tick while a +late subscriber is advanced from a stale offset to the retained base offset. + +Start a Temporal service, then run the worker: + +```bash +dotnet run -- worker +``` + +In another terminal, run the subscribers: + +```bash +dotnet run +``` diff --git a/src/WorkflowStreams/BoundedLog/Scenario.cs b/src/WorkflowStreams/BoundedLog/Scenario.cs index 02598ff..b7120dd 100644 --- a/src/WorkflowStreams/BoundedLog/Scenario.cs +++ b/src/WorkflowStreams/BoundedLog/Scenario.cs @@ -1,9 +1,10 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BoundedLog; using Temporalio.Client; +using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static partial class Scenarios +public static class Scenario { private const int TickCount = 30; private const int KeepLast = 5; @@ -16,13 +17,13 @@ public static async Task RunTruncatingTickerAsync(ITemporalClient client) var handle = await client.StartWorkflowAsync( (TickerWorkflow wf) => wf.RunAsync( new TickerInput(TickCount, KeepLast, TruncateEvery, null, null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); + new(workflowId, Constants.TaskQueue)); Console.WriteLine($"Started workflow: {workflowId}"); async Task FastSubscriberAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe()) + await foreach (var item in streamClient.Topic(Constants.TopicTick).Subscribe()) { var evt = Decode(client, item); Console.WriteLine($"[fast] offset={item.Offset,3} n={evt.N}"); @@ -43,7 +44,7 @@ async Task LateSubscriberAsync() } var first = true; - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe(StaleOffset)) + await foreach (var item in streamClient.Topic(Constants.TopicTick).Subscribe(StaleOffset)) { var evt = Decode(client, item); if (first && item.Offset > StaleOffset) @@ -65,4 +66,7 @@ async Task LateSubscriberAsync() await Task.WhenAll(FastSubscriberAsync(), LateSubscriberAsync()); Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/BoundedLog/TemporalioSamples.WorkflowStreams.BoundedLog.csproj b/src/WorkflowStreams/BoundedLog/TemporalioSamples.WorkflowStreams.BoundedLog.csproj new file mode 100644 index 0000000..c1cc2f4 --- /dev/null +++ b/src/WorkflowStreams/BoundedLog/TemporalioSamples.WorkflowStreams.BoundedLog.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs b/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs index 062904e..fee0833 100644 --- a/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs +++ b/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.BoundedLog; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Workflows; @@ -14,7 +14,7 @@ public class TickerWorkflow [WorkflowRun] public async Task RunAsync(TickerInput input) { - var tick = stream.Topic(WorkflowStreamsConstants.TopicTick); + var tick = stream.Topic(Constants.TopicTick); var interval = input.Interval ?? TimeSpan.FromMilliseconds(200); for (var n = 0; n < input.Count; n++) @@ -32,7 +32,7 @@ public async Task RunAsync(TickerInput input) } } - await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + await Workflow.DelayAsync(Constants.DrainDelay); return $"ticker emitted {input.Count} events"; } } diff --git a/src/WorkflowStreams/ExternalPublisher/Constants.cs b/src/WorkflowStreams/ExternalPublisher/Constants.cs new file mode 100644 index 0000000..51249e1 --- /dev/null +++ b/src/WorkflowStreams/ExternalPublisher/Constants.cs @@ -0,0 +1,12 @@ +namespace TemporalioSamples.WorkflowStreams.ExternalPublisher; + +public static class Constants +{ + public const string TaskQueue = "workflow-streams-external-publisher"; + + public const string TopicNews = "news"; + + public const string DoneHeadline = "-- end of feed --"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/src/WorkflowStreams/ExternalPublisher/HubWorkflow.workflow.cs b/src/WorkflowStreams/ExternalPublisher/HubWorkflow.workflow.cs index 6c5c1f7..820bdb3 100644 --- a/src/WorkflowStreams/ExternalPublisher/HubWorkflow.workflow.cs +++ b/src/WorkflowStreams/ExternalPublisher/HubWorkflow.workflow.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ExternalPublisher; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Workflows; @@ -15,7 +15,7 @@ public class HubWorkflow public async Task RunAsync(HubInput input) { await Workflow.WaitConditionAsync(() => closed); - await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + await Workflow.DelayAsync(Constants.DrainDelay); return $"hub {input.HubId} closed"; } diff --git a/src/WorkflowStreams/ExternalPublisher/Models.cs b/src/WorkflowStreams/ExternalPublisher/Models.cs index 2cd3459..1a4ac4f 100644 --- a/src/WorkflowStreams/ExternalPublisher/Models.cs +++ b/src/WorkflowStreams/ExternalPublisher/Models.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ExternalPublisher; using Temporalio.Extensions.WorkflowStreams; diff --git a/src/WorkflowStreams/ExternalPublisher/Program.cs b/src/WorkflowStreams/ExternalPublisher/Program.cs new file mode 100644 index 0000000..fa82847 --- /dev/null +++ b/src/WorkflowStreams/ExternalPublisher/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams.ExternalPublisher; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +if (args.ElementAtOrDefault(0) == "worker") +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(Constants.TaskQueue).AddWorkflow()); + await worker.ExecuteAsync(tokenSource.Token); +} +else +{ + await Scenario.RunExternalPublisherAsync(client); +} diff --git a/src/WorkflowStreams/ExternalPublisher/README.md b/src/WorkflowStreams/ExternalPublisher/README.md new file mode 100644 index 0000000..9b09f8a --- /dev/null +++ b/src/WorkflowStreams/ExternalPublisher/README.md @@ -0,0 +1,16 @@ +# External publisher + +A Workflow hosts the stream while a normal client publishes news and another client subscribes. +The client signals the Workflow to close after flushing a sentinel event. + +Start a Temporal service, then run the worker: + +```bash +dotnet run -- worker +``` + +In another terminal, run the publisher and subscriber: + +```bash +dotnet run +``` diff --git a/src/WorkflowStreams/ExternalPublisher/Scenario.cs b/src/WorkflowStreams/ExternalPublisher/Scenario.cs index 0fdd1f8..dea2a15 100644 --- a/src/WorkflowStreams/ExternalPublisher/Scenario.cs +++ b/src/WorkflowStreams/ExternalPublisher/Scenario.cs @@ -1,9 +1,10 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ExternalPublisher; using Temporalio.Client; +using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static partial class Scenarios +public static class Scenario { private static readonly string[] Headlines = [ @@ -17,16 +18,16 @@ public static async Task RunExternalPublisherAsync(ITemporalClient client) var workflowId = $"workflow-streams-hub-{Guid.NewGuid()}"; var handle = await client.StartWorkflowAsync( (HubWorkflow wf) => wf.RunAsync(new HubInput("newsroom", null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); + new(workflowId, Constants.TaskQueue)); Console.WriteLine($"Started workflow: {workflowId}"); async Task SubscribeAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicNews).Subscribe()) + await foreach (var item in streamClient.Topic(Constants.TopicNews).Subscribe()) { var evt = Decode(client, item); - if (evt.Headline == WorkflowStreamsConstants.DoneHeadline) + if (evt.Headline == Constants.DoneHeadline) { break; } @@ -37,14 +38,14 @@ async Task SubscribeAsync() async Task PublishAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - var news = streamClient.Topic(WorkflowStreamsConstants.TopicNews); + var news = streamClient.Topic(Constants.TopicNews); foreach (var headline in Headlines) { news.Publish(new NewsEvent(headline)); Console.WriteLine($"[publisher] sent: {headline}"); await Task.Delay(TimeSpan.FromMilliseconds(500)); } - news.Publish(new NewsEvent(WorkflowStreamsConstants.DoneHeadline), forceFlush: true); + news.Publish(new NewsEvent(Constants.DoneHeadline), forceFlush: true); await streamClient.FlushAsync(); await handle.SignalAsync(wf => wf.CloseAsync()); Console.WriteLine("[publisher] signaled close"); @@ -53,4 +54,7 @@ async Task PublishAsync() await Task.WhenAll(SubscribeAsync(), PublishAsync()); Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/ExternalPublisher/TemporalioSamples.WorkflowStreams.ExternalPublisher.csproj b/src/WorkflowStreams/ExternalPublisher/TemporalioSamples.WorkflowStreams.ExternalPublisher.csproj new file mode 100644 index 0000000..c1cc2f4 --- /dev/null +++ b/src/WorkflowStreams/ExternalPublisher/TemporalioSamples.WorkflowStreams.ExternalPublisher.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/src/WorkflowStreams/ListenerSubscription/Constants.cs b/src/WorkflowStreams/ListenerSubscription/Constants.cs new file mode 100644 index 0000000..cf4eed4 --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/Constants.cs @@ -0,0 +1,12 @@ +namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; + +public static class Constants +{ + public const string TaskQueue = "workflow-streams-listener-subscription"; + + public const string TopicStatus = "status"; + + public const string TopicProgress = "progress"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/src/WorkflowStreams/ListenerSubscription/Models.cs b/src/WorkflowStreams/ListenerSubscription/Models.cs new file mode 100644 index 0000000..ed1554d --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/Models.cs @@ -0,0 +1,9 @@ +namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; + +using Temporalio.Extensions.WorkflowStreams; + +public record OrderInput(string OrderId, WorkflowStreamState? StreamState = null); + +public record StatusEvent(string Kind, string OrderId); + +public record ProgressEvent(string Message); diff --git a/src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs b/src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs new file mode 100644 index 0000000..700fe03 --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs @@ -0,0 +1,31 @@ +namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; + +using Temporalio.Extensions.WorkflowStreams; +using Temporalio.Workflows; + +[Workflow] +public class OrderWorkflow +{ + private readonly WorkflowStream stream; + + [WorkflowInit] + public OrderWorkflow(OrderInput input) => stream = new(input.StreamState); + + [WorkflowRun] + public async Task RunAsync(OrderInput input) + { + var status = stream.Topic(Constants.TopicStatus); + var progress = stream.Topic(Constants.TopicProgress); + + status.Publish(new StatusEvent("received", input.OrderId)); + var chargeId = await Workflow.ExecuteActivityAsync( + () => PaymentActivities.ChargeCardAsync(input.OrderId), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(1), }); + status.Publish(new StatusEvent("shipped", input.OrderId)); + progress.Publish(new ProgressEvent($"charge id: {chargeId}")); + status.Publish(new StatusEvent("complete", input.OrderId)); + + await Workflow.DelayAsync(Constants.DrainDelay); + return chargeId; + } +} diff --git a/src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs b/src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs new file mode 100644 index 0000000..8b8958e --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs @@ -0,0 +1,26 @@ +namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; + +using Microsoft.Extensions.Logging; +using Temporalio.Activities; +using Temporalio.Extensions.WorkflowStreams; + +public static class PaymentActivities +{ + [Activity] + public static async Task ChargeCardAsync(string orderId) + { + await using var streamClient = WorkflowStreamClient.FromActivity( + new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); + var progress = streamClient.Topic(Constants.TopicProgress); + + progress.Publish(new ProgressEvent("charging card...")); + ActivityExecutionContext.Current.Logger.LogInformation( + "Charging card for order {OrderId}", orderId); + await Task.Delay( + TimeSpan.FromSeconds(1), + ActivityExecutionContext.Current.CancellationToken); + progress.Publish(new ProgressEvent("card charged")); + await streamClient.FlushAsync(); + return $"charge-{orderId}"; + } +} diff --git a/src/WorkflowStreams/ListenerSubscription/Program.cs b/src/WorkflowStreams/ListenerSubscription/Program.cs new file mode 100644 index 0000000..9788d46 --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/Program.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams.ListenerSubscription; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +if (args.ElementAtOrDefault(0) == "worker") +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(Constants.TaskQueue). + AddActivity(PaymentActivities.ChargeCardAsync). + AddWorkflow()); + await worker.ExecuteAsync(tokenSource.Token); +} +else +{ + await Scenario.RunListenerAsync(client); +} diff --git a/src/WorkflowStreams/ListenerSubscription/README.md b/src/WorkflowStreams/ListenerSubscription/README.md new file mode 100644 index 0000000..83b77f8 --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/README.md @@ -0,0 +1,16 @@ +# Listener subscription + +Starts two order Workflows and subscribes to each with `WorkflowStreamListener`, demonstrating +serialized callbacks and task-based backpressure. + +Start a Temporal service, then run the worker: + +```bash +dotnet run -- worker +``` + +In another terminal, run the listener: + +```bash +dotnet run +``` diff --git a/src/WorkflowStreams/ListenerSubscription/Scenario.cs b/src/WorkflowStreams/ListenerSubscription/Scenario.cs index 1e8c1a8..e42b2d5 100644 --- a/src/WorkflowStreams/ListenerSubscription/Scenario.cs +++ b/src/WorkflowStreams/ListenerSubscription/Scenario.cs @@ -1,9 +1,10 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; using Temporalio.Client; +using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static partial class Scenarios +public static class Scenario { private static readonly string[] OrderIds = new[] { "order-A", "order-B" }; @@ -19,7 +20,7 @@ public static async Task RunListenerAsync(ITemporalClient client) var workflowId = $"workflow-streams-listener-{orderId}-{Guid.NewGuid()}"; var workflowHandle = await client.StartWorkflowAsync( (OrderWorkflow wf) => wf.RunAsync(new OrderInput(orderId, null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); + new(workflowId, Constants.TaskQueue)); Console.WriteLine($"Started workflow: {workflowId}"); #pragma warning disable CA2000 // The client is closed in the method's finally block @@ -30,8 +31,8 @@ public static async Task RunListenerAsync(ITemporalClient client) { Topics = new List { - WorkflowStreamsConstants.TopicStatus, - WorkflowStreamsConstants.TopicProgress, + Constants.TopicStatus, + Constants.TopicProgress, }, }, new RenderingListener(client, orderId, renderLock)); @@ -72,12 +73,12 @@ public override async Task OnNextAsync(WorkflowStreamItem item) await renderLock.WaitAsync().ConfigureAwait(false); try { - if (item.Topic == WorkflowStreamsConstants.TopicStatus) + if (item.Topic == Constants.TopicStatus) { Console.WriteLine( $"[{orderId}] [status] {Decode(client, item).Kind}"); } - else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + else if (item.Topic == Constants.TopicProgress) { Console.WriteLine( $"[{orderId}] [progress] {Decode(client, item).Message}"); @@ -95,4 +96,7 @@ public override void OnCompleted() => public override void OnError(Exception failure) => Console.Error.WriteLine($"[{orderId}] stream failed: {failure}"); } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj b/src/WorkflowStreams/ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj new file mode 100644 index 0000000..c1cc2f4 --- /dev/null +++ b/src/WorkflowStreams/ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/src/WorkflowStreams/LlmTokenStreaming/Constants.cs b/src/WorkflowStreams/LlmTokenStreaming/Constants.cs new file mode 100644 index 0000000..14adf4c --- /dev/null +++ b/src/WorkflowStreams/LlmTokenStreaming/Constants.cs @@ -0,0 +1,14 @@ +namespace TemporalioSamples.WorkflowStreams.LlmTokenStreaming; + +public static class Constants +{ + public const string TaskQueue = "workflow-streams-llm-token-streaming"; + + public const string TopicDelta = "delta"; + + public const string TopicComplete = "complete"; + + public const string TopicRetry = "retry"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs b/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs index f66fc1d..d28ee30 100644 --- a/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs +++ b/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.LlmTokenStreaming; using System.ClientModel; using System.ClientModel.Primitives; @@ -21,9 +21,9 @@ public static async Task StreamCompletionAsync(LlmInput input) await using var streamClient = WorkflowStreamClient.FromActivity( new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); - var deltas = streamClient.Topic(WorkflowStreamsConstants.TopicDelta); - var complete = streamClient.Topic(WorkflowStreamsConstants.TopicComplete); - var retry = streamClient.Topic(WorkflowStreamsConstants.TopicRetry); + var deltas = streamClient.Topic(Constants.TopicDelta); + var complete = streamClient.Topic(Constants.TopicComplete); + var retry = streamClient.Topic(Constants.TopicRetry); var activityContext = ActivityExecutionContext.Current; if (activityContext.Info.Attempt > 1) diff --git a/src/WorkflowStreams/LlmTokenStreaming/LlmWorkflow.workflow.cs b/src/WorkflowStreams/LlmTokenStreaming/LlmWorkflow.workflow.cs index 8752cde..50d106f 100644 --- a/src/WorkflowStreams/LlmTokenStreaming/LlmWorkflow.workflow.cs +++ b/src/WorkflowStreams/LlmTokenStreaming/LlmWorkflow.workflow.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.LlmTokenStreaming; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Workflows; @@ -15,7 +15,7 @@ public async Task RunAsync(LlmInput input) var result = await Workflow.ExecuteActivityAsync( () => LlmActivities.StreamCompletionAsync(input), new() { StartToCloseTimeout = TimeSpan.FromMinutes(2), }); - await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + await Workflow.DelayAsync(Constants.DrainDelay); return result; } } diff --git a/src/WorkflowStreams/LlmTokenStreaming/Models.cs b/src/WorkflowStreams/LlmTokenStreaming/Models.cs index baf57e9..0f5d27d 100644 --- a/src/WorkflowStreams/LlmTokenStreaming/Models.cs +++ b/src/WorkflowStreams/LlmTokenStreaming/Models.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.LlmTokenStreaming; using Temporalio.Extensions.WorkflowStreams; diff --git a/src/WorkflowStreams/LlmTokenStreaming/Program.cs b/src/WorkflowStreams/LlmTokenStreaming/Program.cs new file mode 100644 index 0000000..f3d71dd --- /dev/null +++ b/src/WorkflowStreams/LlmTokenStreaming/Program.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams.LlmTokenStreaming; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +if (args.ElementAtOrDefault(0) == "worker") +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(Constants.TaskQueue). + AddActivity(LlmActivities.StreamCompletionAsync). + AddWorkflow()); + await worker.ExecuteAsync(tokenSource.Token); +} +else +{ + await Scenario.RunLlmAsync( + client, + args.Length > 0 ? string.Join(' ', args) : null); +} diff --git a/src/WorkflowStreams/LlmTokenStreaming/README.md b/src/WorkflowStreams/LlmTokenStreaming/README.md new file mode 100644 index 0000000..9f9cac8 --- /dev/null +++ b/src/WorkflowStreams/LlmTokenStreaming/README.md @@ -0,0 +1,18 @@ +# LLM token streaming + +An Activity streams token deltas from OpenAI through a Workflow Stream. Retry events tell the +subscriber to clear partial output and render the new attempt from scratch. + +Set `OPENAI_API_KEY`, start a Temporal service, and run the worker: + +```bash +export OPENAI_API_KEY=... +dotnet run -- worker +``` + +In another terminal, run the subscriber with an optional prompt: + +```bash +dotnet run +dotnet run -- "Explain durable execution in one paragraph." +``` diff --git a/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs b/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs index 3c7f77b..4670c1e 100644 --- a/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs +++ b/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs @@ -1,9 +1,10 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.LlmTokenStreaming; using Temporalio.Client; +using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static partial class Scenarios +public static class Scenario { public static async Task RunLlmAsync(ITemporalClient client, string? prompt) { @@ -18,7 +19,7 @@ public static async Task RunLlmAsync(ITemporalClient client, string? prompt) var workflowId = $"workflow-streams-llm-{Guid.NewGuid()}"; var handle = await client.StartWorkflowAsync( (LlmWorkflow wf) => wf.RunAsync(input), - new(workflowId, WorkflowStreamsConstants.LlmTaskQueue)); + new(workflowId, Constants.TaskQueue)); Console.WriteLine( $"[llm {workflowId}] streaming response from {input.Model}, awaiting first token..."); Console.WriteLine(); @@ -29,14 +30,14 @@ public static async Task RunLlmAsync(ITemporalClient client, string? prompt) { Topics = new List { - WorkflowStreamsConstants.TopicDelta, - WorkflowStreamsConstants.TopicRetry, - WorkflowStreamsConstants.TopicComplete, + Constants.TopicDelta, + Constants.TopicRetry, + Constants.TopicComplete, }, }; await foreach (var item in streamClient.Subscribe(options)) { - if (item.Topic == WorkflowStreamsConstants.TopicRetry) + if (item.Topic == Constants.TopicRetry) { var evt = Decode(client, item); Console.Write(ansiRestoreAndClear); @@ -44,11 +45,11 @@ public static async Task RunLlmAsync(ITemporalClient client, string? prompt) Console.WriteLine(); Console.Write(ansiSave); } - else if (item.Topic == WorkflowStreamsConstants.TopicDelta) + else if (item.Topic == Constants.TopicDelta) { Console.Write(Decode(client, item).Text); } - else if (item.Topic == WorkflowStreamsConstants.TopicComplete) + else if (item.Topic == Constants.TopicComplete) { _ = Decode(client, item); Console.WriteLine(); @@ -59,4 +60,7 @@ public static async Task RunLlmAsync(ITemporalClient client, string? prompt) var result = await handle.GetResultAsync(); Console.WriteLine($"[workflow result: {result.Length} chars]"); } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/TemporalioSamples.WorkflowStreams.csproj b/src/WorkflowStreams/LlmTokenStreaming/TemporalioSamples.WorkflowStreams.LlmTokenStreaming.csproj similarity index 100% rename from src/WorkflowStreams/TemporalioSamples.WorkflowStreams.csproj rename to src/WorkflowStreams/LlmTokenStreaming/TemporalioSamples.WorkflowStreams.LlmTokenStreaming.csproj diff --git a/src/WorkflowStreams/Program.cs b/src/WorkflowStreams/Program.cs deleted file mode 100644 index 9afbad9..0000000 --- a/src/WorkflowStreams/Program.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Microsoft.Extensions.Logging; -using Temporalio.Client; -using Temporalio.Common.EnvConfig; -using Temporalio.Worker; -using TemporalioSamples.WorkflowStreams; - -var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); -connectOptions.TargetHost ??= "localhost:7233"; -connectOptions.LoggerFactory = LoggerFactory.Create(builder => - builder. - AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). - SetMinimumLevel(LogLevel.Information)); -var client = await TemporalClient.ConnectAsync(connectOptions); - -async Task RunWorkerAsync(bool llm) -{ - using var tokenSource = new CancellationTokenSource(); - Console.CancelKeyPress += (_, eventArgs) => - { - tokenSource.Cancel(); - eventArgs.Cancel = true; - }; - - var workerOptions = new TemporalWorkerOptions( - llm ? WorkflowStreamsConstants.LlmTaskQueue : WorkflowStreamsConstants.TaskQueue); - if (llm) - { - workerOptions. - AddActivity(LlmActivities.StreamCompletionAsync). - AddWorkflow(); - } - else - { - workerOptions. - AddActivity(PaymentActivities.ChargeCardAsync). - AddWorkflow(). - AddWorkflow(). - AddWorkflow(). - AddWorkflow(); - } - - Console.WriteLine($"Running {(llm ? "LLM " : string.Empty)}worker"); - using var worker = new TemporalWorker(client, workerOptions); - try - { - await worker.ExecuteAsync(tokenSource.Token); - } - catch (OperationCanceledException) - { - Console.WriteLine("Worker cancelled"); - } -} - -switch (args.ElementAtOrDefault(0)) -{ - case "worker": - await RunWorkerAsync(llm: false); - break; - case "publisher": - await Scenarios.RunPublisherAsync(client); - break; - case "listener": - await Scenarios.RunListenerAsync(client); - break; - case "reconnecting": - await Scenarios.RunReconnectingSubscriberAsync(client); - break; - case "external-publisher": - await Scenarios.RunExternalPublisherAsync(client); - break; - case "ticker": - await Scenarios.RunTruncatingTickerAsync(client); - break; - case "llm-worker": - await RunWorkerAsync(llm: true); - break; - case "llm": - await Scenarios.RunLlmAsync( - client, - args.Length > 1 ? string.Join(' ', args.Skip(1)) : null); - break; - default: - throw new ArgumentException( - "Must pass 'worker', 'publisher', 'listener', 'reconnecting', " + - "'external-publisher', 'ticker', 'llm-worker', or 'llm'"); -} diff --git a/src/WorkflowStreams/README.md b/src/WorkflowStreams/README.md index 1c2db4c..f545186 100644 --- a/src/WorkflowStreams/README.md +++ b/src/WorkflowStreams/README.md @@ -8,7 +8,8 @@ Workflow. Workflow code and external clients publish to named topics through Sig long-poll through Updates, and a Query exposes the current global offset. The extension handles batching, publisher deduplication, topic filtering, Continue-As-New handoff, and truncation. -The sample contains six independently linkable scenarios: +Each scenario is a self-contained project with its own worker, client, models, constants, and +instructions: - [Basic publish/subscribe](BasicPublishSubscribe) - [Listener subscription](ListenerSubscription) @@ -17,81 +18,5 @@ The sample contains six independently linkable scenarios: - [Bounded log](BoundedLog) - [LLM token streaming](LlmTokenStreaming) -The first five commands use the shared worker; LLM streaming uses a separate worker and task queue -because it requires an OpenAI API key. - -## Run the sample - -First see the [repository README](../../README.md) for prerequisites and start a Temporal service, -for example with `temporal server start-dev`. - -Start the shared worker: - -```bash -dotnet run -- worker -``` - -Then run a scenario from another terminal: - -```bash -dotnet run -- publisher # Basic mixed-topic publish/subscribe -dotnet run -- listener # Listener callbacks and task-based backpressure -dotnet run -- reconnecting # Disconnect and resume from a saved offset -dotnet run -- external-publisher # Publish from code outside a Workflow or Activity -dotnet run -- ticker # Bound the log by truncating old entries -``` - -### [Basic publish/subscribe](BasicPublishSubscribe) - -`OrderWorkflow` publishes lifecycle events to the `status` topic. Its payment Activity creates a -client with `WorkflowStreamClient.FromActivity()` and publishes finer-grained events to `progress`. -The subscriber consumes both topics with `await foreach` and decodes each payload by topic. - -### [Listener subscription](ListenerSubscription) - -The listener scenario starts two order Workflows and subscribes to each with -`WorkflowStreamListener`. `Subscribe` returns immediately with a -`WorkflowStreamSubscriptionHandle`. Callbacks are serialized per subscription, and the Task -returned by `OnNextAsync` prevents the next item and poll from being delivered until processing -finishes. Unlike a blocking iterator, both this API and .NET's `await foreach` API poll fully -asynchronously without occupying a thread. - -### [Reconnecting subscriber](ReconnectingSubscriber) - -The first subscriber reads two pipeline stages and saves one past the last global offset it saw. -A fresh client then calls `Subscribe(savedOffset)` and receives the remaining stages without gaps -or duplicates. Offsets are global across topics, not per-topic. - -### [External publisher](ExternalPublisher) - -`HubWorkflow` only hosts the stream. A normal client publishes news into it while another client -subscribes, then signals the Workflow to close after flushing a sentinel event. - -### [Bounded log](BoundedLog) - -`TickerWorkflow` periodically calls `Truncate()` to retain only recent events. A fast subscriber -sees every tick. A late subscriber requests an offset that has already been truncated and is -automatically advanced to the retained base offset, demonstrating the bounded-history tradeoff. - -## [LLM token streaming](LlmTokenStreaming) - -Set `OPENAI_API_KEY` and start the dedicated worker: - -```bash -export OPENAI_API_KEY=... -dotnet run -- llm-worker -``` - -Then run the subscriber with an optional prompt: - -```bash -dotnet run -- llm -dotnet run -- llm "Explain durable execution in one paragraph." -``` - -The Workflow performs no network I/O. Its Activity calls OpenAI with streaming enabled, publishes -token deltas to `delta`, and publishes the final text to `complete`. OpenAI's own retries are -disabled so Temporal owns retry behavior. When an Activity retry starts, it publishes to `retry`; -the subscriber clears the partial terminal output and renders the new attempt from scratch. - -To exercise that path, stop the LLM worker while output is streaming and restart it. +See the [repository README](../../README.md) for common prerequisites. Each scenario README has +the commands for running that project. diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Constants.cs b/src/WorkflowStreams/ReconnectingSubscriber/Constants.cs new file mode 100644 index 0000000..d59b2ec --- /dev/null +++ b/src/WorkflowStreams/ReconnectingSubscriber/Constants.cs @@ -0,0 +1,10 @@ +namespace TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; + +public static class Constants +{ + public const string TaskQueue = "workflow-streams-reconnecting-subscriber"; + + public const string TopicStatus = "status"; + + public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); +} diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Models.cs b/src/WorkflowStreams/ReconnectingSubscriber/Models.cs index a9a2334..7cb9043 100644 --- a/src/WorkflowStreams/ReconnectingSubscriber/Models.cs +++ b/src/WorkflowStreams/ReconnectingSubscriber/Models.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; using Temporalio.Extensions.WorkflowStreams; diff --git a/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs b/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs index 6f8f76e..74e9bdc 100644 --- a/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs +++ b/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Workflows; @@ -14,7 +14,7 @@ public class PipelineWorkflow [WorkflowRun] public async Task RunAsync(PipelineInput input) { - var status = stream.Topic(WorkflowStreamsConstants.TopicStatus); + var status = stream.Topic(Constants.TopicStatus); var stageInterval = input.StageInterval ?? TimeSpan.FromSeconds(2); var stages = new[] { @@ -34,7 +34,7 @@ public async Task RunAsync(PipelineInput input) } } - await Workflow.DelayAsync(WorkflowStreamsConstants.DrainDelay); + await Workflow.DelayAsync(Constants.DrainDelay); return $"pipeline {input.PipelineId} done"; } } diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Program.cs b/src/WorkflowStreams/ReconnectingSubscriber/Program.cs new file mode 100644 index 0000000..542b2f3 --- /dev/null +++ b/src/WorkflowStreams/ReconnectingSubscriber/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; + +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); +connectOptions.TargetHost ??= "localhost:7233"; +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); + +if (args.ElementAtOrDefault(0) == "worker") +{ + using var tokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + tokenSource.Cancel(); + eventArgs.Cancel = true; + }; + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(Constants.TaskQueue).AddWorkflow()); + await worker.ExecuteAsync(tokenSource.Token); +} +else +{ + await Scenario.RunReconnectingSubscriberAsync(client); +} diff --git a/src/WorkflowStreams/ReconnectingSubscriber/README.md b/src/WorkflowStreams/ReconnectingSubscriber/README.md new file mode 100644 index 0000000..e01e049 --- /dev/null +++ b/src/WorkflowStreams/ReconnectingSubscriber/README.md @@ -0,0 +1,16 @@ +# Reconnecting subscriber + +A subscriber disconnects after two pipeline stages, saves the next offset, and resumes without +gaps or duplicates using a fresh client. + +Start a Temporal service, then run the worker: + +```bash +dotnet run -- worker +``` + +In another terminal, run the subscriber: + +```bash +dotnet run +``` diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs index 74f38d1..911b398 100644 --- a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs +++ b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs @@ -1,16 +1,17 @@ -namespace TemporalioSamples.WorkflowStreams; +namespace TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; using Temporalio.Client; +using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; -public static partial class Scenarios +public static class Scenario { public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) { var workflowId = $"workflow-streams-pipeline-{Guid.NewGuid()}"; var handle = await client.StartWorkflowAsync( (PipelineWorkflow wf) => wf.RunAsync(new PipelineInput("pipeline-7", null, null)), - new(workflowId, WorkflowStreamsConstants.TaskQueue)); + new(workflowId, Constants.TaskQueue)); Console.WriteLine($"Started workflow: {workflowId}"); long nextOffset = 0; @@ -18,7 +19,7 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) await using (var streamClient = new WorkflowStreamClient(client, workflowId)) { var seen = 0; - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe()) + await foreach (var item in streamClient.Topic(Constants.TopicStatus).Subscribe()) { var evt = Decode(client, item); nextOffset = item.Offset + 1; @@ -34,7 +35,7 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) Console.WriteLine("--- phase 2: reconnected subscriber ---"); await using (var streamClient = new WorkflowStreamClient(client, workflowId)) { - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe(nextOffset)) + await foreach (var item in streamClient.Topic(Constants.TopicStatus).Subscribe(nextOffset)) { var evt = Decode(client, item); Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); @@ -47,4 +48,7 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/ReconnectingSubscriber/TemporalioSamples.WorkflowStreams.ReconnectingSubscriber.csproj b/src/WorkflowStreams/ReconnectingSubscriber/TemporalioSamples.WorkflowStreams.ReconnectingSubscriber.csproj new file mode 100644 index 0000000..c1cc2f4 --- /dev/null +++ b/src/WorkflowStreams/ReconnectingSubscriber/TemporalioSamples.WorkflowStreams.ReconnectingSubscriber.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/src/WorkflowStreams/Scenarios.cs b/src/WorkflowStreams/Scenarios.cs deleted file mode 100644 index 526ee7c..0000000 --- a/src/WorkflowStreams/Scenarios.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TemporalioSamples.WorkflowStreams; - -using Temporalio.Client; -using Temporalio.Converters; -using Temporalio.Extensions.WorkflowStreams; - -public static partial class Scenarios -{ - private static T Decode(ITemporalClient client, WorkflowStreamItem item) => - client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); -} diff --git a/src/WorkflowStreams/Shared.cs b/src/WorkflowStreams/Shared.cs deleted file mode 100644 index 74f303a..0000000 --- a/src/WorkflowStreams/Shared.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace TemporalioSamples.WorkflowStreams; - -public static class WorkflowStreamsConstants -{ - public const string TaskQueue = "workflow-streams"; - - public const string LlmTaskQueue = "workflow-streams-llm"; - - public const string TopicStatus = "status"; - - public const string TopicProgress = "progress"; - - public const string TopicNews = "news"; - - public const string TopicTick = "tick"; - - public const string TopicDelta = "delta"; - - public const string TopicComplete = "complete"; - - public const string TopicRetry = "retry"; - - public const string DoneHeadline = "-- end of feed --"; - - public static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(500); -} diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj index f143517..5f0b76e 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -39,7 +39,12 @@ - + + + + + + diff --git a/tests/WorkflowStreams/WorkflowStreamsTests.cs b/tests/WorkflowStreams/WorkflowStreamsTests.cs index cee5d64..5fe7fa0 100644 --- a/tests/WorkflowStreams/WorkflowStreamsTests.cs +++ b/tests/WorkflowStreams/WorkflowStreamsTests.cs @@ -5,9 +5,14 @@ namespace TemporalioSamples.Tests.WorkflowStreams; using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Worker; -using TemporalioSamples.WorkflowStreams; using Xunit; using Xunit.Abstractions; +using Basic = TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; +using Bounded = TemporalioSamples.WorkflowStreams.BoundedLog; +using External = TemporalioSamples.WorkflowStreams.ExternalPublisher; +using Listener = TemporalioSamples.WorkflowStreams.ListenerSubscription; +using Llm = TemporalioSamples.WorkflowStreams.LlmTokenStreaming; +using Reconnecting = TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; public class WorkflowStreamsTests : WorkflowEnvironmentTestBase { @@ -25,13 +30,13 @@ public async Task OrderWorkflow_PublishesWorkflowAndActivityEvents() using var worker = new TemporalWorker( Client, NewWorker(). - AddActivity(PaymentActivities.ChargeCardAsync). - AddWorkflow()); + AddActivity(Basic.PaymentActivities.ChargeCardAsync). + AddWorkflow()); await worker.ExecuteAsync(async () => { var workflowId = $"workflow-streams-order-{Guid.NewGuid()}"; var handle = await Client.StartWorkflowAsync( - (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-42", null)), + (Basic.OrderWorkflow wf) => wf.RunAsync(new Basic.OrderInput("order-42", null)), new(workflowId, worker.Options.TaskQueue!)); await using var streamClient = new WorkflowStreamClient(Client, workflowId); @@ -41,21 +46,21 @@ await worker.ExecuteAsync(async () => { Topics = new List { - WorkflowStreamsConstants.TopicStatus, - WorkflowStreamsConstants.TopicProgress, + Basic.Constants.TopicStatus, + Basic.Constants.TopicProgress, }, })) { - if (item.Topic == WorkflowStreamsConstants.TopicStatus) + if (item.Topic == Basic.Constants.TopicStatus) { - var status = Decode(item); + var status = Decode(item); statuses.Add(status.Kind); if (status.Kind == "complete") { break; } } - else if (item.Topic == WorkflowStreamsConstants.TopicProgress) + else if (item.Topic == Basic.Constants.TopicProgress) { progressCount++; } @@ -73,13 +78,14 @@ public async Task ListenerSubscription_DeliversSerializedCallbacks() using var worker = new TemporalWorker( Client, NewWorker(). - AddActivity(PaymentActivities.ChargeCardAsync). - AddWorkflow()); + AddActivity(Listener.PaymentActivities.ChargeCardAsync). + AddWorkflow()); await worker.ExecuteAsync(async () => { var workflowId = $"workflow-streams-listener-{Guid.NewGuid()}"; var handle = await Client.StartWorkflowAsync( - (OrderWorkflow wf) => wf.RunAsync(new OrderInput("order-listener", null)), + (Listener.OrderWorkflow wf) => + wf.RunAsync(new Listener.OrderInput("order-listener", null)), new(workflowId, worker.Options.TaskQueue!)); await using var streamClient = new WorkflowStreamClient(Client, workflowId); var listener = new RecordingListener(); @@ -88,8 +94,8 @@ await worker.ExecuteAsync(async () => { Topics = new List { - WorkflowStreamsConstants.TopicStatus, - WorkflowStreamsConstants.TopicProgress, + Listener.Constants.TopicStatus, + Listener.Constants.TopicProgress, }, }, listener); @@ -107,13 +113,13 @@ public async Task ReconnectingSubscriber_ResumesAtNextOffset() { using var worker = new TemporalWorker( Client, - NewWorker().AddWorkflow()); + NewWorker().AddWorkflow()); await worker.ExecuteAsync(async () => { var workflowId = $"workflow-streams-pipeline-{Guid.NewGuid()}"; var handle = await Client.StartWorkflowAsync( - (PipelineWorkflow wf) => wf.RunAsync( - new PipelineInput( + (Reconnecting.PipelineWorkflow wf) => wf.RunAsync( + new Reconnecting.PipelineInput( "pipeline-test", TimeSpan.FromMilliseconds(50), null)), @@ -123,7 +129,7 @@ await worker.ExecuteAsync(async () => long nextOffset = 0; await using (var firstClient = new WorkflowStreamClient(Client, workflowId)) { - await foreach (var item in firstClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe()) + await foreach (var item in firstClient.Topic(Reconnecting.Constants.TopicStatus).Subscribe()) { offsets.Add(item.Offset); nextOffset = item.Offset + 1; @@ -137,10 +143,10 @@ await worker.ExecuteAsync(async () => var remainingStages = new List(); await using (var secondClient = new WorkflowStreamClient(Client, workflowId)) { - await foreach (var item in secondClient.Topic(WorkflowStreamsConstants.TopicStatus).Subscribe(nextOffset)) + await foreach (var item in secondClient.Topic(Reconnecting.Constants.TopicStatus).Subscribe(nextOffset)) { offsets.Add(item.Offset); - var stage = Decode(item).Stage; + var stage = Decode(item).Stage; remainingStages.Add(stage); if (stage == "complete") { @@ -161,21 +167,23 @@ public async Task ExternalPublisher_PublishesAndClosesHub() { using var worker = new TemporalWorker( Client, - NewWorker().AddWorkflow()); + NewWorker().AddWorkflow()); await worker.ExecuteAsync(async () => { var workflowId = $"workflow-streams-hub-{Guid.NewGuid()}"; var handle = await Client.StartWorkflowAsync( - (HubWorkflow wf) => wf.RunAsync(new HubInput("test-hub", null)), + (External.HubWorkflow wf) => + wf.RunAsync(new External.HubInput("test-hub", null)), new(workflowId, worker.Options.TaskQueue!)); await using var subscriber = new WorkflowStreamClient(Client, workflowId); await using var publisher = new WorkflowStreamClient(Client, workflowId); - publisher.Topic(WorkflowStreamsConstants.TopicNews).Publish(new NewsEvent("test headline"), forceFlush: true); + publisher.Topic(External.Constants.TopicNews). + Publish(new External.NewsEvent("test headline"), forceFlush: true); await publisher.FlushAsync(); - await foreach (var item in subscriber.Topic(WorkflowStreamsConstants.TopicNews).Subscribe()) + await foreach (var item in subscriber.Topic(External.Constants.TopicNews).Subscribe()) { - Assert.Equal("test headline", Decode(item).Headline); + Assert.Equal("test headline", Decode(item).Headline); break; } await handle.SignalAsync(wf => wf.CloseAsync()); @@ -188,20 +196,20 @@ public async Task TruncatingTicker_FastForwardsStaleOffset() { using var worker = new TemporalWorker( Client, - NewWorker().AddWorkflow()); + NewWorker().AddWorkflow()); await worker.ExecuteAsync(async () => { var workflowId = $"workflow-streams-ticker-{Guid.NewGuid()}"; var handle = await Client.StartWorkflowAsync( - (TickerWorkflow wf) => wf.RunAsync( - new TickerInput(20, 5, 5, TimeSpan.Zero, null)), + (Bounded.TickerWorkflow wf) => wf.RunAsync( + new Bounded.TickerInput(20, 5, 5, TimeSpan.Zero, null)), new(workflowId, worker.Options.TaskQueue!)); await using var streamClient = new WorkflowStreamClient(Client, workflowId); await AssertMore.EventuallyAsync(async () => Assert.True(await streamClient.GetOffsetAsync() >= 10)); - await foreach (var item in streamClient.Topic(WorkflowStreamsConstants.TopicTick).Subscribe(1)) + await foreach (var item in streamClient.Topic(Bounded.Constants.TopicTick).Subscribe(1)) { Assert.True(item.Offset >= 5); break; @@ -214,18 +222,19 @@ await AssertMore.EventuallyAsync(async () => public async Task LlmWorkflow_ReturnsMockedStreamingResult() { [Activity("StreamCompletion")] - static Task StreamCompletionAsync(LlmInput input) => + static Task StreamCompletionAsync(Llm.LlmInput input) => Task.FromResult("a streamed answer"); using var worker = new TemporalWorker( Client, NewWorker(). AddActivity(StreamCompletionAsync). - AddWorkflow()); + AddWorkflow()); await worker.ExecuteAsync(async () => { var result = await Client.ExecuteWorkflowAsync( - (LlmWorkflow wf) => wf.RunAsync(new LlmInput("hello", "gpt-4o-mini", null)), + (Llm.LlmWorkflow wf) => + wf.RunAsync(new Llm.LlmInput("hello", "gpt-4o-mini", null)), new($"workflow-streams-llm-{Guid.NewGuid()}", worker.Options.TaskQueue!)); Assert.Equal("a streamed answer", result); }); @@ -265,12 +274,12 @@ public override async Task OnNextAsync(WorkflowStreamItem item) try { await Task.Yield(); - if (item.Topic == WorkflowStreamsConstants.TopicStatus) + if (item.Topic == Listener.Constants.TopicStatus) { lock (lockObj) { statuses.Add(DataConverter.Default.PayloadConverter. - ToValue(item.Payload).Kind); + ToValue(item.Payload).Kind); } } } From aa81abf03f76d309c1191361bf411f71c4ec9246 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Fri, 4 Sep 2026 16:59:33 -0700 Subject: [PATCH 5/6] Update workflow stream samples --- TemporalioSamples.sln | 2 +- .../BasicPublishSubscribe/Scenario.cs | 4 +- src/WorkflowStreams/BoundedLog/Scenario.cs | 4 +- .../Constants.cs | 4 +- .../Models.cs | 2 +- .../OrderWorkflow.workflow.cs | 2 +- .../PaymentActivities.cs | 2 +- .../Program.cs | 4 +- .../ConcurrentSubscriptions/README.md | 16 +++ .../ConcurrentSubscriptions/Scenario.cs | 81 ++++++++++++++ ...lowStreams.ConcurrentSubscriptions.csproj} | 0 .../ExternalPublisher/Scenario.cs | 2 +- .../ListenerSubscription/README.md | 16 --- .../ListenerSubscription/Scenario.cs | 102 ------------------ .../LlmTokenStreaming/Scenario.cs | 4 +- src/WorkflowStreams/README.md | 2 +- .../ReconnectingSubscriber/Scenario.cs | 4 +- tests/TemporalioSamples.Tests.csproj | 2 +- tests/WorkflowStreams/WorkflowStreamsTests.cs | 97 +++++------------ 19 files changed, 143 insertions(+), 207 deletions(-) rename src/WorkflowStreams/{ListenerSubscription => ConcurrentSubscriptions}/Constants.cs (59%) rename src/WorkflowStreams/{ListenerSubscription => ConcurrentSubscriptions}/Models.cs (77%) rename src/WorkflowStreams/{ListenerSubscription => ConcurrentSubscriptions}/OrderWorkflow.workflow.cs (93%) rename src/WorkflowStreams/{ListenerSubscription => ConcurrentSubscriptions}/PaymentActivities.cs (93%) rename src/WorkflowStreams/{ListenerSubscription => ConcurrentSubscriptions}/Program.cs (89%) create mode 100644 src/WorkflowStreams/ConcurrentSubscriptions/README.md create mode 100644 src/WorkflowStreams/ConcurrentSubscriptions/Scenario.cs rename src/WorkflowStreams/{ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj => ConcurrentSubscriptions/TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions.csproj} (100%) delete mode 100644 src/WorkflowStreams/ListenerSubscription/README.md delete mode 100644 src/WorkflowStreams/ListenerSubscription/Scenario.cs diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index 715cb94..8b23ef7 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -147,7 +147,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.ExternalPublisher", "src\WorkflowStreams\ExternalPublisher\TemporalioSamples.WorkflowStreams.ExternalPublisher.csproj", "{B2222222-2222-4222-8222-222222222222}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.ListenerSubscription", "src\WorkflowStreams\ListenerSubscription\TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj", "{C3333333-3333-4333-8333-333333333333}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions", "src\WorkflowStreams\ConcurrentSubscriptions\TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions.csproj", "{C3333333-3333-4333-8333-333333333333}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.WorkflowStreams.LlmTokenStreaming", "src\WorkflowStreams\LlmTokenStreaming\TemporalioSamples.WorkflowStreams.LlmTokenStreaming.csproj", "{D4444444-4444-4444-8444-444444444444}" EndProject diff --git a/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs b/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs index 70cfe33..6ec3de2 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/Scenario.cs @@ -15,7 +15,7 @@ public static async Task RunPublisherAsync(ITemporalClient client) Console.WriteLine($"Started workflow: {workflowId}"); await using var streamClient = new WorkflowStreamClient(client, workflowId); - var options = new SubscribeOptions + var options = new WorkflowStreamSubscribeOptions { Topics = new List { @@ -23,7 +23,7 @@ public static async Task RunPublisherAsync(ITemporalClient client) Constants.TopicProgress, }, }; - await foreach (var item in streamClient.Subscribe(options)) + await foreach (var item in streamClient.SubscribeAsync(options)) { if (item.Topic == Constants.TopicStatus) { diff --git a/src/WorkflowStreams/BoundedLog/Scenario.cs b/src/WorkflowStreams/BoundedLog/Scenario.cs index b7120dd..0acabfc 100644 --- a/src/WorkflowStreams/BoundedLog/Scenario.cs +++ b/src/WorkflowStreams/BoundedLog/Scenario.cs @@ -23,7 +23,7 @@ public static async Task RunTruncatingTickerAsync(ITemporalClient client) async Task FastSubscriberAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(Constants.TopicTick).Subscribe()) + await foreach (var item in streamClient.Topic(Constants.TopicTick).SubscribeAsync()) { var evt = Decode(client, item); Console.WriteLine($"[fast] offset={item.Offset,3} n={evt.N}"); @@ -44,7 +44,7 @@ async Task LateSubscriberAsync() } var first = true; - await foreach (var item in streamClient.Topic(Constants.TopicTick).Subscribe(StaleOffset)) + await foreach (var item in streamClient.Topic(Constants.TopicTick).SubscribeAsync(StaleOffset)) { var evt = Decode(client, item); if (first && item.Offset > StaleOffset) diff --git a/src/WorkflowStreams/ListenerSubscription/Constants.cs b/src/WorkflowStreams/ConcurrentSubscriptions/Constants.cs similarity index 59% rename from src/WorkflowStreams/ListenerSubscription/Constants.cs rename to src/WorkflowStreams/ConcurrentSubscriptions/Constants.cs index cf4eed4..042ac25 100644 --- a/src/WorkflowStreams/ListenerSubscription/Constants.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/Constants.cs @@ -1,8 +1,8 @@ -namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; +namespace TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; public static class Constants { - public const string TaskQueue = "workflow-streams-listener-subscription"; + public const string TaskQueue = "workflow-streams-concurrent-subscriptions"; public const string TopicStatus = "status"; diff --git a/src/WorkflowStreams/ListenerSubscription/Models.cs b/src/WorkflowStreams/ConcurrentSubscriptions/Models.cs similarity index 77% rename from src/WorkflowStreams/ListenerSubscription/Models.cs rename to src/WorkflowStreams/ConcurrentSubscriptions/Models.cs index ed1554d..903b651 100644 --- a/src/WorkflowStreams/ListenerSubscription/Models.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/Models.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; +namespace TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; using Temporalio.Extensions.WorkflowStreams; diff --git a/src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs b/src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs similarity index 93% rename from src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs rename to src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs index 700fe03..a39602b 100644 --- a/src/WorkflowStreams/ListenerSubscription/OrderWorkflow.workflow.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; +namespace TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; using Temporalio.Extensions.WorkflowStreams; using Temporalio.Workflows; diff --git a/src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs b/src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs similarity index 93% rename from src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs rename to src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs index 8b8958e..d60730c 100644 --- a/src/WorkflowStreams/ListenerSubscription/PaymentActivities.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs @@ -1,4 +1,4 @@ -namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; +namespace TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; using Microsoft.Extensions.Logging; using Temporalio.Activities; diff --git a/src/WorkflowStreams/ListenerSubscription/Program.cs b/src/WorkflowStreams/ConcurrentSubscriptions/Program.cs similarity index 89% rename from src/WorkflowStreams/ListenerSubscription/Program.cs rename to src/WorkflowStreams/ConcurrentSubscriptions/Program.cs index 9788d46..2f984dc 100644 --- a/src/WorkflowStreams/ListenerSubscription/Program.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/Program.cs @@ -2,7 +2,7 @@ using Temporalio.Client; using Temporalio.Common.EnvConfig; using Temporalio.Worker; -using TemporalioSamples.WorkflowStreams.ListenerSubscription; +using TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); connectOptions.TargetHost ??= "localhost:7233"; @@ -29,5 +29,5 @@ } else { - await Scenario.RunListenerAsync(client); + await Scenario.RunSubscriptionsAsync(client); } diff --git a/src/WorkflowStreams/ConcurrentSubscriptions/README.md b/src/WorkflowStreams/ConcurrentSubscriptions/README.md new file mode 100644 index 0000000..75a44fe --- /dev/null +++ b/src/WorkflowStreams/ConcurrentSubscriptions/README.md @@ -0,0 +1,16 @@ +# Concurrent subscriptions + +Runs two order Workflows and consumes each reusable `IAsyncEnumerable` +concurrently. Awaiting each item provides natural backpressure within each subscription. + +Start a Temporal service, then run the worker: + +```bash +dotnet run -- worker +``` + +In another terminal, run the subscribers: + +```bash +dotnet run +``` diff --git a/src/WorkflowStreams/ConcurrentSubscriptions/Scenario.cs b/src/WorkflowStreams/ConcurrentSubscriptions/Scenario.cs new file mode 100644 index 0000000..0b56167 --- /dev/null +++ b/src/WorkflowStreams/ConcurrentSubscriptions/Scenario.cs @@ -0,0 +1,81 @@ +namespace TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; + +using Temporalio.Client; +using Temporalio.Converters; +using Temporalio.Extensions.WorkflowStreams; + +public static class Scenario +{ + private static readonly string[] OrderIds = new[] { "order-A", "order-B" }; + + public static async Task RunSubscriptionsAsync(ITemporalClient client) + { + var workflowHandles = new List>(); + var streamClients = new List(); + var subscriptions = new List(); + + foreach (var orderId in OrderIds) + { + var workflowId = $"workflow-streams-concurrent-{orderId}-{Guid.NewGuid()}"; + var workflowHandle = await client.StartWorkflowAsync( + (OrderWorkflow wf) => wf.RunAsync(new OrderInput(orderId, null)), + new(workflowId, Constants.TaskQueue)); + Console.WriteLine($"Started workflow: {workflowId}"); + +#pragma warning disable CA2000 // The client is closed in the method's finally block + var streamClient = new WorkflowStreamClient(client, workflowId); +#pragma warning restore CA2000 + workflowHandles.Add(workflowHandle); + streamClients.Add(streamClient); + subscriptions.Add(RenderSubscriptionAsync(streamClient, client, orderId)); + } + + try + { + await Task.WhenAll(subscriptions); + for (var i = 0; i < workflowHandles.Count; i++) + { + var result = await workflowHandles[i].GetResultAsync(); + Console.WriteLine($"[{OrderIds[i]}] workflow result: {result}"); + } + } + finally + { + foreach (var streamClient in streamClients) + { + await streamClient.DisposeAsync(); + } + } + } + + private static async Task RenderSubscriptionAsync( + WorkflowStreamClient streamClient, + ITemporalClient client, + string orderId) + { + await foreach (var item in streamClient.SubscribeAsync(new() + { + Topics = new List + { + Constants.TopicStatus, + Constants.TopicProgress, + }, + })) + { + if (item.Topic == Constants.TopicStatus) + { + Console.WriteLine( + $"[{orderId}] [status] {Decode(client, item).Kind}"); + } + else if (item.Topic == Constants.TopicProgress) + { + Console.WriteLine( + $"[{orderId}] [progress] {Decode(client, item).Message}"); + } + } + Console.WriteLine($"[{orderId}] stream completed"); + } + + private static T Decode(ITemporalClient client, WorkflowStreamItem item) => + client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); +} diff --git a/src/WorkflowStreams/ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj b/src/WorkflowStreams/ConcurrentSubscriptions/TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions.csproj similarity index 100% rename from src/WorkflowStreams/ListenerSubscription/TemporalioSamples.WorkflowStreams.ListenerSubscription.csproj rename to src/WorkflowStreams/ConcurrentSubscriptions/TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions.csproj diff --git a/src/WorkflowStreams/ExternalPublisher/Scenario.cs b/src/WorkflowStreams/ExternalPublisher/Scenario.cs index dea2a15..9a87897 100644 --- a/src/WorkflowStreams/ExternalPublisher/Scenario.cs +++ b/src/WorkflowStreams/ExternalPublisher/Scenario.cs @@ -24,7 +24,7 @@ public static async Task RunExternalPublisherAsync(ITemporalClient client) async Task SubscribeAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(Constants.TopicNews).Subscribe()) + await foreach (var item in streamClient.Topic(Constants.TopicNews).SubscribeAsync()) { var evt = Decode(client, item); if (evt.Headline == Constants.DoneHeadline) diff --git a/src/WorkflowStreams/ListenerSubscription/README.md b/src/WorkflowStreams/ListenerSubscription/README.md deleted file mode 100644 index 83b77f8..0000000 --- a/src/WorkflowStreams/ListenerSubscription/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Listener subscription - -Starts two order Workflows and subscribes to each with `WorkflowStreamListener`, demonstrating -serialized callbacks and task-based backpressure. - -Start a Temporal service, then run the worker: - -```bash -dotnet run -- worker -``` - -In another terminal, run the listener: - -```bash -dotnet run -``` diff --git a/src/WorkflowStreams/ListenerSubscription/Scenario.cs b/src/WorkflowStreams/ListenerSubscription/Scenario.cs deleted file mode 100644 index e42b2d5..0000000 --- a/src/WorkflowStreams/ListenerSubscription/Scenario.cs +++ /dev/null @@ -1,102 +0,0 @@ -namespace TemporalioSamples.WorkflowStreams.ListenerSubscription; - -using Temporalio.Client; -using Temporalio.Converters; -using Temporalio.Extensions.WorkflowStreams; - -public static class Scenario -{ - private static readonly string[] OrderIds = new[] { "order-A", "order-B" }; - - public static async Task RunListenerAsync(ITemporalClient client) - { - var workflowHandles = new List>(); - var streamClients = new List(); - var subscriptionHandles = new List(); - using var renderLock = new SemaphoreSlim(1, 1); - - foreach (var orderId in OrderIds) - { - var workflowId = $"workflow-streams-listener-{orderId}-{Guid.NewGuid()}"; - var workflowHandle = await client.StartWorkflowAsync( - (OrderWorkflow wf) => wf.RunAsync(new OrderInput(orderId, null)), - new(workflowId, Constants.TaskQueue)); - Console.WriteLine($"Started workflow: {workflowId}"); - -#pragma warning disable CA2000 // The client is closed in the method's finally block - var streamClient = new WorkflowStreamClient(client, workflowId); -#pragma warning restore CA2000 - var subscriptionHandle = streamClient.Subscribe( - new SubscribeOptions - { - Topics = new List - { - Constants.TopicStatus, - Constants.TopicProgress, - }, - }, - new RenderingListener(client, orderId, renderLock)); - workflowHandles.Add(workflowHandle); - streamClients.Add(streamClient); - subscriptionHandles.Add(subscriptionHandle); - } - - try - { - await Task.WhenAll(subscriptionHandles.Select(handle => handle.Completion)); - for (var i = 0; i < workflowHandles.Count; i++) - { - var result = await workflowHandles[i].GetResultAsync(); - Console.WriteLine($"[{OrderIds[i]}] workflow result: {result}"); - } - } - finally - { - foreach (var handle in subscriptionHandles) - { - handle.Dispose(); - } - foreach (var streamClient in streamClients) - { - await streamClient.CloseAsync(); - } - } - } - - private sealed class RenderingListener( - ITemporalClient client, - string orderId, - SemaphoreSlim renderLock) : WorkflowStreamListener - { - public override async Task OnNextAsync(WorkflowStreamItem item) - { - await renderLock.WaitAsync().ConfigureAwait(false); - try - { - if (item.Topic == Constants.TopicStatus) - { - Console.WriteLine( - $"[{orderId}] [status] {Decode(client, item).Kind}"); - } - else if (item.Topic == Constants.TopicProgress) - { - Console.WriteLine( - $"[{orderId}] [progress] {Decode(client, item).Message}"); - } - } - finally - { - renderLock.Release(); - } - } - - public override void OnCompleted() => - Console.WriteLine($"[{orderId}] stream completed"); - - public override void OnError(Exception failure) => - Console.Error.WriteLine($"[{orderId}] stream failed: {failure}"); - } - - private static T Decode(ITemporalClient client, WorkflowStreamItem item) => - client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); -} diff --git a/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs b/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs index 4670c1e..4de7c2a 100644 --- a/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs +++ b/src/WorkflowStreams/LlmTokenStreaming/Scenario.cs @@ -26,7 +26,7 @@ public static async Task RunLlmAsync(ITemporalClient client, string? prompt) Console.Write(ansiSave); await using var streamClient = new WorkflowStreamClient(client, workflowId); - var options = new SubscribeOptions + var options = new WorkflowStreamSubscribeOptions { Topics = new List { @@ -35,7 +35,7 @@ public static async Task RunLlmAsync(ITemporalClient client, string? prompt) Constants.TopicComplete, }, }; - await foreach (var item in streamClient.Subscribe(options)) + await foreach (var item in streamClient.SubscribeAsync(options)) { if (item.Topic == Constants.TopicRetry) { diff --git a/src/WorkflowStreams/README.md b/src/WorkflowStreams/README.md index f545186..758e013 100644 --- a/src/WorkflowStreams/README.md +++ b/src/WorkflowStreams/README.md @@ -12,7 +12,7 @@ Each scenario is a self-contained project with its own worker, client, models, c instructions: - [Basic publish/subscribe](BasicPublishSubscribe) -- [Listener subscription](ListenerSubscription) +- [Concurrent subscriptions](ConcurrentSubscriptions) - [Reconnecting subscriber](ReconnectingSubscriber) - [External publisher](ExternalPublisher) - [Bounded log](BoundedLog) diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs index 911b398..36a0943 100644 --- a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs +++ b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs @@ -19,7 +19,7 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) await using (var streamClient = new WorkflowStreamClient(client, workflowId)) { var seen = 0; - await foreach (var item in streamClient.Topic(Constants.TopicStatus).Subscribe()) + await foreach (var item in streamClient.Topic(Constants.TopicStatus).SubscribeAsync()) { var evt = Decode(client, item); nextOffset = item.Offset + 1; @@ -35,7 +35,7 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) Console.WriteLine("--- phase 2: reconnected subscriber ---"); await using (var streamClient = new WorkflowStreamClient(client, workflowId)) { - await foreach (var item in streamClient.Topic(Constants.TopicStatus).Subscribe(nextOffset)) + await foreach (var item in streamClient.Topic(Constants.TopicStatus).SubscribeAsync(nextOffset)) { var evt = Decode(client, item); Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj index 5f0b76e..92e0b77 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -42,7 +42,7 @@ - + diff --git a/tests/WorkflowStreams/WorkflowStreamsTests.cs b/tests/WorkflowStreams/WorkflowStreamsTests.cs index 5fe7fa0..c6b2ecb 100644 --- a/tests/WorkflowStreams/WorkflowStreamsTests.cs +++ b/tests/WorkflowStreams/WorkflowStreamsTests.cs @@ -9,8 +9,8 @@ namespace TemporalioSamples.Tests.WorkflowStreams; using Xunit.Abstractions; using Basic = TemporalioSamples.WorkflowStreams.BasicPublishSubscribe; using Bounded = TemporalioSamples.WorkflowStreams.BoundedLog; +using Concurrent = TemporalioSamples.WorkflowStreams.ConcurrentSubscriptions; using External = TemporalioSamples.WorkflowStreams.ExternalPublisher; -using Listener = TemporalioSamples.WorkflowStreams.ListenerSubscription; using Llm = TemporalioSamples.WorkflowStreams.LlmTokenStreaming; using Reconnecting = TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; @@ -42,7 +42,7 @@ await worker.ExecuteAsync(async () => var statuses = new List(); var progressCount = 0; - await foreach (var item in streamClient.Subscribe(new() + await foreach (var item in streamClient.SubscribeAsync(new() { Topics = new List { @@ -73,38 +73,41 @@ await worker.ExecuteAsync(async () => } [Fact] - public async Task ListenerSubscription_DeliversSerializedCallbacks() + public async Task SubscriptionAsyncEnumerable_DeliversItemsInOrder() { using var worker = new TemporalWorker( Client, NewWorker(). - AddActivity(Listener.PaymentActivities.ChargeCardAsync). - AddWorkflow()); + AddActivity(Concurrent.PaymentActivities.ChargeCardAsync). + AddWorkflow()); await worker.ExecuteAsync(async () => { - var workflowId = $"workflow-streams-listener-{Guid.NewGuid()}"; + var workflowId = $"workflow-streams-concurrent-{Guid.NewGuid()}"; var handle = await Client.StartWorkflowAsync( - (Listener.OrderWorkflow wf) => - wf.RunAsync(new Listener.OrderInput("order-listener", null)), + (Concurrent.OrderWorkflow wf) => + wf.RunAsync(new Concurrent.OrderInput("order-concurrent", null)), new(workflowId, worker.Options.TaskQueue!)); await using var streamClient = new WorkflowStreamClient(Client, workflowId); - var listener = new RecordingListener(); - using var subscription = streamClient.Subscribe( - new SubscribeOptions + var statuses = new List(); + await foreach (var item in streamClient.SubscribeAsync( + new WorkflowStreamSubscribeOptions { Topics = new List { - Listener.Constants.TopicStatus, - Listener.Constants.TopicProgress, + Concurrent.Constants.TopicStatus, + Concurrent.Constants.TopicProgress, }, - }, - listener); + })) + { + await Task.Yield(); + if (item.Topic == Concurrent.Constants.TopicStatus) + { + statuses.Add(Decode(item).Kind); + } + } - await subscription.Completion; - Assert.Equal("charge-order-listener", await handle.GetResultAsync()); - Assert.Equal(1, listener.MaxConcurrentCallbacks); - Assert.Equal(ExpectedOrderStatuses, listener.Statuses); - Assert.True(listener.Completed); + Assert.Equal("charge-order-concurrent", await handle.GetResultAsync()); + Assert.Equal(ExpectedOrderStatuses, statuses); }); } @@ -129,7 +132,7 @@ await worker.ExecuteAsync(async () => long nextOffset = 0; await using (var firstClient = new WorkflowStreamClient(Client, workflowId)) { - await foreach (var item in firstClient.Topic(Reconnecting.Constants.TopicStatus).Subscribe()) + await foreach (var item in firstClient.Topic(Reconnecting.Constants.TopicStatus).SubscribeAsync()) { offsets.Add(item.Offset); nextOffset = item.Offset + 1; @@ -143,7 +146,7 @@ await worker.ExecuteAsync(async () => var remainingStages = new List(); await using (var secondClient = new WorkflowStreamClient(Client, workflowId)) { - await foreach (var item in secondClient.Topic(Reconnecting.Constants.TopicStatus).Subscribe(nextOffset)) + await foreach (var item in secondClient.Topic(Reconnecting.Constants.TopicStatus).SubscribeAsync(nextOffset)) { offsets.Add(item.Offset); var stage = Decode(item).Stage; @@ -181,7 +184,7 @@ await worker.ExecuteAsync(async () => Publish(new External.NewsEvent("test headline"), forceFlush: true); await publisher.FlushAsync(); - await foreach (var item in subscriber.Topic(External.Constants.TopicNews).Subscribe()) + await foreach (var item in subscriber.Topic(External.Constants.TopicNews).SubscribeAsync()) { Assert.Equal("test headline", Decode(item).Headline); break; @@ -209,7 +212,7 @@ await worker.ExecuteAsync(async () => await AssertMore.EventuallyAsync(async () => Assert.True(await streamClient.GetOffsetAsync() >= 10)); - await foreach (var item in streamClient.Topic(Bounded.Constants.TopicTick).Subscribe(1)) + await foreach (var item in streamClient.Topic(Bounded.Constants.TopicTick).SubscribeAsync(1)) { Assert.True(item.Offset >= 5); break; @@ -245,50 +248,4 @@ private TemporalWorkerOptions NewWorker() => private T Decode(WorkflowStreamItem item) => Client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); - - private sealed class RecordingListener : WorkflowStreamListener - { - private readonly object lockObj = new(); - private readonly List statuses = new(); - private int activeCallbacks; - - public bool Completed { get; private set; } - - public int MaxConcurrentCallbacks { get; private set; } - - public IReadOnlyCollection Statuses - { - get - { - lock (lockObj) - { - return statuses.ToArray(); - } - } - } - - public override async Task OnNextAsync(WorkflowStreamItem item) - { - var active = Interlocked.Increment(ref activeCallbacks); - MaxConcurrentCallbacks = Math.Max(MaxConcurrentCallbacks, active); - try - { - await Task.Yield(); - if (item.Topic == Listener.Constants.TopicStatus) - { - lock (lockObj) - { - statuses.Add(DataConverter.Default.PayloadConverter. - ToValue(item.Payload).Kind); - } - } - } - finally - { - _ = Interlocked.Decrement(ref activeCallbacks); - } - } - - public override void OnCompleted() => Completed = true; - } } From 124660d4ef9c89698acfba0f4f9ba09f9e3268db Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 14 Sep 2026 15:39:49 -0700 Subject: [PATCH 6/6] Update workflow stream samples for typed topics --- .../OrderWorkflow.workflow.cs | 4 ++-- .../PaymentActivities.cs | 2 +- src/WorkflowStreams/BoundedLog/Scenario.cs | 14 +++++------- .../BoundedLog/TickerWorkflow.workflow.cs | 2 +- .../OrderWorkflow.workflow.cs | 4 ++-- .../PaymentActivities.cs | 2 +- .../ExternalPublisher/Scenario.cs | 11 ++++------ .../LlmTokenStreaming/LlmActivities.cs | 6 ++--- .../PipelineWorkflow.workflow.cs | 2 +- .../ReconnectingSubscriber/Scenario.cs | 14 +++++------- tests/WorkflowStreams/WorkflowStreamsTests.cs | 22 +++++++++++++------ 11 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs b/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs index e12b4a1..0377df4 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/OrderWorkflow.workflow.cs @@ -14,8 +14,8 @@ public class OrderWorkflow [WorkflowRun] public async Task RunAsync(OrderInput input) { - var status = stream.Topic(Constants.TopicStatus); - var progress = stream.Topic(Constants.TopicProgress); + var status = stream.GetTopic(Constants.TopicStatus); + var progress = stream.GetTopic(Constants.TopicProgress); status.Publish(new StatusEvent("received", input.OrderId)); var chargeId = await Workflow.ExecuteActivityAsync( diff --git a/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs b/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs index 50a7c79..4c34d3e 100644 --- a/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs +++ b/src/WorkflowStreams/BasicPublishSubscribe/PaymentActivities.cs @@ -11,7 +11,7 @@ public static async Task ChargeCardAsync(string orderId) { await using var streamClient = WorkflowStreamClient.FromActivity( new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); - var progress = streamClient.Topic(Constants.TopicProgress); + var progress = streamClient.GetTopic(Constants.TopicProgress); progress.Publish(new ProgressEvent("charging card...")); ActivityExecutionContext.Current.Logger.LogInformation( diff --git a/src/WorkflowStreams/BoundedLog/Scenario.cs b/src/WorkflowStreams/BoundedLog/Scenario.cs index 0acabfc..094f214 100644 --- a/src/WorkflowStreams/BoundedLog/Scenario.cs +++ b/src/WorkflowStreams/BoundedLog/Scenario.cs @@ -1,7 +1,6 @@ namespace TemporalioSamples.WorkflowStreams.BoundedLog; using Temporalio.Client; -using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; public static class Scenario @@ -23,9 +22,10 @@ public static async Task RunTruncatingTickerAsync(ITemporalClient client) async Task FastSubscriberAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(Constants.TopicTick).SubscribeAsync()) + await foreach (var item in streamClient. + GetTopic(Constants.TopicTick).SubscribeAsync()) { - var evt = Decode(client, item); + var evt = item.Value; Console.WriteLine($"[fast] offset={item.Offset,3} n={evt.N}"); if (evt.N == TickCount - 1) { @@ -44,9 +44,10 @@ async Task LateSubscriberAsync() } var first = true; - await foreach (var item in streamClient.Topic(Constants.TopicTick).SubscribeAsync(StaleOffset)) + await foreach (var item in streamClient. + GetTopic(Constants.TopicTick).SubscribeAsync(StaleOffset)) { - var evt = Decode(client, item); + var evt = item.Value; if (first && item.Offset > StaleOffset) { Console.WriteLine( @@ -66,7 +67,4 @@ async Task LateSubscriberAsync() await Task.WhenAll(FastSubscriberAsync(), LateSubscriberAsync()); Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } - - private static T Decode(ITemporalClient client, WorkflowStreamItem item) => - client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs b/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs index fee0833..74d0f90 100644 --- a/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs +++ b/src/WorkflowStreams/BoundedLog/TickerWorkflow.workflow.cs @@ -14,7 +14,7 @@ public class TickerWorkflow [WorkflowRun] public async Task RunAsync(TickerInput input) { - var tick = stream.Topic(Constants.TopicTick); + var tick = stream.GetTopic(Constants.TopicTick); var interval = input.Interval ?? TimeSpan.FromMilliseconds(200); for (var n = 0; n < input.Count; n++) diff --git a/src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs b/src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs index a39602b..88133f0 100644 --- a/src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/OrderWorkflow.workflow.cs @@ -14,8 +14,8 @@ public class OrderWorkflow [WorkflowRun] public async Task RunAsync(OrderInput input) { - var status = stream.Topic(Constants.TopicStatus); - var progress = stream.Topic(Constants.TopicProgress); + var status = stream.GetTopic(Constants.TopicStatus); + var progress = stream.GetTopic(Constants.TopicProgress); status.Publish(new StatusEvent("received", input.OrderId)); var chargeId = await Workflow.ExecuteActivityAsync( diff --git a/src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs b/src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs index d60730c..41d9b6d 100644 --- a/src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs +++ b/src/WorkflowStreams/ConcurrentSubscriptions/PaymentActivities.cs @@ -11,7 +11,7 @@ public static async Task ChargeCardAsync(string orderId) { await using var streamClient = WorkflowStreamClient.FromActivity( new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); - var progress = streamClient.Topic(Constants.TopicProgress); + var progress = streamClient.GetTopic(Constants.TopicProgress); progress.Publish(new ProgressEvent("charging card...")); ActivityExecutionContext.Current.Logger.LogInformation( diff --git a/src/WorkflowStreams/ExternalPublisher/Scenario.cs b/src/WorkflowStreams/ExternalPublisher/Scenario.cs index 9a87897..03a1ecf 100644 --- a/src/WorkflowStreams/ExternalPublisher/Scenario.cs +++ b/src/WorkflowStreams/ExternalPublisher/Scenario.cs @@ -1,7 +1,6 @@ namespace TemporalioSamples.WorkflowStreams.ExternalPublisher; using Temporalio.Client; -using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; public static class Scenario @@ -24,9 +23,10 @@ public static async Task RunExternalPublisherAsync(ITemporalClient client) async Task SubscribeAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - await foreach (var item in streamClient.Topic(Constants.TopicNews).SubscribeAsync()) + await foreach (var item in streamClient. + GetTopic(Constants.TopicNews).SubscribeAsync()) { - var evt = Decode(client, item); + var evt = item.Value; if (evt.Headline == Constants.DoneHeadline) { break; @@ -38,7 +38,7 @@ async Task SubscribeAsync() async Task PublishAsync() { await using var streamClient = new WorkflowStreamClient(client, workflowId); - var news = streamClient.Topic(Constants.TopicNews); + var news = streamClient.GetTopic(Constants.TopicNews); foreach (var headline in Headlines) { news.Publish(new NewsEvent(headline)); @@ -54,7 +54,4 @@ async Task PublishAsync() await Task.WhenAll(SubscribeAsync(), PublishAsync()); Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } - - private static T Decode(ITemporalClient client, WorkflowStreamItem item) => - client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs b/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs index d28ee30..1bad542 100644 --- a/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs +++ b/src/WorkflowStreams/LlmTokenStreaming/LlmActivities.cs @@ -21,9 +21,9 @@ public static async Task StreamCompletionAsync(LlmInput input) await using var streamClient = WorkflowStreamClient.FromActivity( new() { BatchInterval = TimeSpan.FromMilliseconds(200), }); - var deltas = streamClient.Topic(Constants.TopicDelta); - var complete = streamClient.Topic(Constants.TopicComplete); - var retry = streamClient.Topic(Constants.TopicRetry); + var deltas = streamClient.GetTopic(Constants.TopicDelta); + var complete = streamClient.GetTopic(Constants.TopicComplete); + var retry = streamClient.GetTopic(Constants.TopicRetry); var activityContext = ActivityExecutionContext.Current; if (activityContext.Info.Attempt > 1) diff --git a/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs b/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs index 74e9bdc..6c49147 100644 --- a/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs +++ b/src/WorkflowStreams/ReconnectingSubscriber/PipelineWorkflow.workflow.cs @@ -14,7 +14,7 @@ public class PipelineWorkflow [WorkflowRun] public async Task RunAsync(PipelineInput input) { - var status = stream.Topic(Constants.TopicStatus); + var status = stream.GetTopic(Constants.TopicStatus); var stageInterval = input.StageInterval ?? TimeSpan.FromSeconds(2); var stages = new[] { diff --git a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs index 36a0943..2822bc3 100644 --- a/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs +++ b/src/WorkflowStreams/ReconnectingSubscriber/Scenario.cs @@ -1,7 +1,6 @@ namespace TemporalioSamples.WorkflowStreams.ReconnectingSubscriber; using Temporalio.Client; -using Temporalio.Converters; using Temporalio.Extensions.WorkflowStreams; public static class Scenario @@ -19,9 +18,10 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) await using (var streamClient = new WorkflowStreamClient(client, workflowId)) { var seen = 0; - await foreach (var item in streamClient.Topic(Constants.TopicStatus).SubscribeAsync()) + await foreach (var item in streamClient. + GetTopic(Constants.TopicStatus).SubscribeAsync()) { - var evt = Decode(client, item); + var evt = item.Value; nextOffset = item.Offset + 1; Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); if (++seen == 2) @@ -35,9 +35,10 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) Console.WriteLine("--- phase 2: reconnected subscriber ---"); await using (var streamClient = new WorkflowStreamClient(client, workflowId)) { - await foreach (var item in streamClient.Topic(Constants.TopicStatus).SubscribeAsync(nextOffset)) + await foreach (var item in streamClient. + GetTopic(Constants.TopicStatus).SubscribeAsync(nextOffset)) { - var evt = Decode(client, item); + var evt = item.Value; Console.WriteLine($"offset={item.Offset} stage={evt.Stage}"); if (evt.Stage == "complete") { @@ -48,7 +49,4 @@ public static async Task RunReconnectingSubscriberAsync(ITemporalClient client) Console.WriteLine($"Workflow result: {await handle.GetResultAsync()}"); } - - private static T Decode(ITemporalClient client, WorkflowStreamItem item) => - client.Options.DataConverter.PayloadConverter.ToValue(item.Payload); } diff --git a/tests/WorkflowStreams/WorkflowStreamsTests.cs b/tests/WorkflowStreams/WorkflowStreamsTests.cs index c6b2ecb..85ecccd 100644 --- a/tests/WorkflowStreams/WorkflowStreamsTests.cs +++ b/tests/WorkflowStreams/WorkflowStreamsTests.cs @@ -132,7 +132,9 @@ await worker.ExecuteAsync(async () => long nextOffset = 0; await using (var firstClient = new WorkflowStreamClient(Client, workflowId)) { - await foreach (var item in firstClient.Topic(Reconnecting.Constants.TopicStatus).SubscribeAsync()) + await foreach (var item in firstClient. + GetTopic(Reconnecting.Constants.TopicStatus). + SubscribeAsync()) { offsets.Add(item.Offset); nextOffset = item.Offset + 1; @@ -146,10 +148,12 @@ await worker.ExecuteAsync(async () => var remainingStages = new List(); await using (var secondClient = new WorkflowStreamClient(Client, workflowId)) { - await foreach (var item in secondClient.Topic(Reconnecting.Constants.TopicStatus).SubscribeAsync(nextOffset)) + await foreach (var item in secondClient. + GetTopic(Reconnecting.Constants.TopicStatus). + SubscribeAsync(nextOffset)) { offsets.Add(item.Offset); - var stage = Decode(item).Stage; + var stage = item.Value.Stage; remainingStages.Add(stage); if (stage == "complete") { @@ -180,13 +184,15 @@ await worker.ExecuteAsync(async () => new(workflowId, worker.Options.TaskQueue!)); await using var subscriber = new WorkflowStreamClient(Client, workflowId); await using var publisher = new WorkflowStreamClient(Client, workflowId); - publisher.Topic(External.Constants.TopicNews). + publisher.GetTopic(External.Constants.TopicNews). Publish(new External.NewsEvent("test headline"), forceFlush: true); await publisher.FlushAsync(); - await foreach (var item in subscriber.Topic(External.Constants.TopicNews).SubscribeAsync()) + await foreach (var item in subscriber. + GetTopic(External.Constants.TopicNews). + SubscribeAsync()) { - Assert.Equal("test headline", Decode(item).Headline); + Assert.Equal("test headline", item.Value.Headline); break; } await handle.SignalAsync(wf => wf.CloseAsync()); @@ -212,7 +218,9 @@ await worker.ExecuteAsync(async () => await AssertMore.EventuallyAsync(async () => Assert.True(await streamClient.GetOffsetAsync() >= 10)); - await foreach (var item in streamClient.Topic(Bounded.Constants.TopicTick).SubscribeAsync(1)) + await foreach (var item in streamClient. + GetTopic(Bounded.Constants.TopicTick). + SubscribeAsync(1)) { Assert.True(item.Offset >= 5); break;