Skip to content

Commit c0c9132

Browse files
brianstrauchclaude
andauthored
Add temporal-workflowstreams contrib module (#2912)
A durable, multi-topic pub/sub log hosted inside a workflow, mirroring the workflow streams contrib packages in the Go, Python, and TypeScript SDKs. External publishers send batches via a signal, subscribers long-poll via an update, and a query exposes the current offset; the wire protocol (handler names, JSON envelope field names, base64-of-proto per-item payload encoding) matches the other SDKs exactly for cross-language interop. The workflow side registers a typed listener and supports publisher dedup, ~1 MB poll response paging, truncation, and continue-as-new state carryover. The client side provides a batching publisher with retry and sequence-based exactly-once delivery, and a blocking subscription iterator that follows continue-as-new chains and ends cleanly on terminal workflow states. Like the Go SDK, the core SDK now permits registering signal, update, and query handlers in the __temporal_workflow_stream_ sub-namespace, which is otherwise reserved. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2c5e662 commit c0c9132

39 files changed

Lines changed: 3871 additions & 6 deletions

.github/CODEOWNERS

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
# For each one, we add the owning team, as well as
1313
# @temporalio/sdk, so the SDK team can continue to
1414
# manage repo-wide concerns
15-
/contrib/temporal-spring-ai/ @temporalio/ai-sdk @temporalio/sdk
15+
/contrib/temporal-spring-ai/ @temporalio/sdk @temporalio/ai-sdk
16+
/contrib/temporal-workflowstreams/ @temporalio/sdk @temporalio/ai-sdk
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Workflow Streams
2+
3+
A durable publish/subscribe log hosted inside a Temporal Workflow.
4+
5+
External code (activities, starters, other processes) publishes messages to
6+
named topics via **signals**; subscribers long-poll for new items via
7+
**updates**; a **query** exposes the current offset. The stream is backed by
8+
Temporal's durable execution, giving ordered, durable, exactly-once delivery
9+
with client-side batching, publisher dedup, continue-as-new survival,
10+
truncation, and ~1 MB response paging.
11+
12+
It is well suited to durable event streams whose cost scales with durable
13+
batches rather than message count. Each poll round-trip costs ~100 ms of
14+
latency, so it is not intended for ultra-low-latency streaming.
15+
16+
All APIs in this module are experimental and may change.
17+
18+
## Workflow side
19+
20+
Construct a `WorkflowStream` once in a `@WorkflowInit` constructor. The factory
21+
registers the publish signal, poll update, and offset query handlers, and a
22+
`@WorkflowInit` constructor runs before any handler dispatch, so polls and
23+
offset queries arriving with the first workflow task (e.g. from
24+
update-with-start) are accepted rather than rejected.
25+
26+
```java
27+
public class MyInput {
28+
public int itemsProcessed; // your own workflow state
29+
public WorkflowStreamState streamState;
30+
}
31+
32+
public class MyWorkflowImpl implements MyWorkflow {
33+
private final WorkflowStream stream;
34+
35+
@WorkflowInit
36+
public MyWorkflowImpl(MyInput input) {
37+
stream = WorkflowStream.newInstance(input.streamState);
38+
}
39+
40+
@Override
41+
public void execute(MyInput input) {
42+
// Optionally publish from workflow code:
43+
stream.topic("events").publish("hello from the workflow");
44+
45+
// Run your workflow; the stream serves external publishers and subscribers
46+
// for as long as the workflow is running. Block until your workflow's exit
47+
// condition is met (here, a `done` flag set elsewhere, e.g. by a signal).
48+
Workflow.await(() -> done);
49+
}
50+
}
51+
```
52+
53+
Constructing the stream at the top of the workflow method also works — signals
54+
received earlier are buffered by the SDK — but polls and offset queries are
55+
rejected until the stream exists, so prefer `@WorkflowInit`.
56+
57+
For workflows that use continue-as-new, the stream's log and offsets must be
58+
carried across each boundary, since continue-as-new starts a fresh run with an
59+
empty history. This is a round-trip with two halves:
60+
61+
- **Capture** the state when rolling over. Instead of calling
62+
`Workflow.continueAsNew` directly, call `stream.continueAsNew`. It drains
63+
pollers, waits for in-flight handlers, snapshots the current stream state, and
64+
hands it to your callback, which builds the argument list for the next run.
65+
The callback is where you assemble the full input — carry forward your own
66+
workflow state alongside the captured `state`:
67+
68+
```java
69+
stream.continueAsNew(state -> {
70+
MyInput next = new MyInput();
71+
next.itemsProcessed = itemsProcessed; // your own state, carried across the boundary
72+
next.streamState = state; // the captured stream state
73+
return new Object[] {next};
74+
});
75+
```
76+
77+
- **Restore** it on the next run. That `MyInput` arrives as the next run's
78+
input, and its `streamState` field is the value already passed to
79+
`WorkflowStream.newInstance` in the example above. It is `null` on a fresh
80+
start and non-null after a roll-over, so the stream rehydrates the log
81+
automatically.
82+
83+
The `WorkflowStreamState` field is what gives the captured stream state
84+
somewhere to live between runs; the other fields on `MyInput` are your own and
85+
are threaded through the same way.
86+
87+
## Publishing (client side)
88+
89+
From an activity, use `fromActivity` to target the parent workflow:
90+
91+
```java
92+
public void publishActivity() {
93+
try (WorkflowStreamClient client = WorkflowStreamClient.fromActivity()) {
94+
TopicHandle topic = client.topic("events");
95+
for (int i = 0; i < 100; i++) {
96+
topic.publish("item " + i);
97+
}
98+
} // client.close() is called on completion, which flushes the remaining buffer
99+
}
100+
```
101+
102+
From a starter or any code with a `WorkflowClient`, use `newInstance` with an
103+
explicit workflow ID:
104+
105+
```java
106+
try (WorkflowStreamClient client = WorkflowStreamClient.newInstance(workflowClient, workflowId)) {
107+
client.topic("events").publish("from outside", /* forceFlush */ true);
108+
}
109+
```
110+
111+
Items are buffered and flushed automatically every batch interval (default 2s),
112+
when the buffer reaches the max batch size, on `forceFlush`, on an explicit
113+
`flush()`, or on `close()`.
114+
115+
## Subscribing
116+
117+
There are two subscriber APIs over one shared poll engine: a non-blocking
118+
listener and a blocking iterator. Neither occupies a thread while a poll is
119+
blocked on the server — polling runs on a small executor shared by all of a
120+
client's subscriptions (2 daemon threads by default; see `pollExecutor`) — so
121+
many concurrent subscriptions do not mean many threads. Either way, the
122+
subscription ends cleanly when the workflow reaches a terminal state,
123+
automatically follows continue-as-new chains, recovers from truncation by
124+
restarting from the current base offset, and also ends when the owning
125+
`WorkflowStreamClient` is closed.
126+
127+
Items carry the raw `io.temporal.api.common.v1.Payload`; decode at the call
128+
site with your data converter. Offsets are **global** (across all topics), not
129+
per-topic.
130+
131+
### Listener (non-blocking)
132+
133+
Pass a `WorkflowStreamListener` to `subscribe` to have items delivered on the
134+
poll executor. Callbacks are serialized (never invoked concurrently) and must
135+
not block; the `CompletionStage` returned by `onNext` is the backpressure
136+
boundary — return `null` (or a completed stage) to receive the next item
137+
immediately, or a pending stage to defer further delivery and polling until it
138+
completes:
139+
140+
```java
141+
SubscribeOptions options = SubscribeOptions.newBuilder()
142+
.setTopics("events") // unset = all topics
143+
.build();
144+
WorkflowStreamSubscriptionHandle handle =
145+
client.subscribe(
146+
options,
147+
new WorkflowStreamListener() {
148+
@Override
149+
public CompletionStage<Void> onNext(WorkflowStreamItem item) {
150+
String value =
151+
DefaultDataConverter.STANDARD_INSTANCE.fromPayload(
152+
item.getPayload(), String.class, String.class);
153+
System.out.printf(
154+
"offset=%d topic=%s value=%s%n", item.getOffset(), item.getTopic(), value);
155+
return null; // or a pending stage to apply backpressure
156+
}
157+
158+
@Override
159+
public void onCompleted() {
160+
System.out.println("stream ended");
161+
}
162+
});
163+
```
164+
165+
`handle.close()` stops the subscription before the next poll (without calling
166+
`onCompleted`); `handle.getDoneFuture()` completes when the subscription ends —
167+
normally on a clean end or close, exceptionally with the failure passed to
168+
`onError`.
169+
170+
### Iterator (blocking)
171+
172+
For synchronous consumers, `subscribe` without a listener returns a blocking,
173+
single-use subscription; the consuming thread blocks waiting for items while
174+
polling still runs on the shared executor:
175+
176+
```java
177+
try (WorkflowStreamSubscription subscription = client.subscribe(options)) {
178+
for (WorkflowStreamItem item : subscription) {
179+
String value =
180+
DefaultDataConverter.STANDARD_INSTANCE.fromPayload(
181+
item.getPayload(), String.class, String.class);
182+
System.out.printf("offset=%d topic=%s value=%s%n", item.getOffset(), item.getTopic(), value);
183+
}
184+
}
185+
```
186+
187+
`close()` stops it before the next poll; items already fetched still drain. An
188+
unrecoverable poll failure is rethrown from `hasNext()`.
189+
190+
## Options
191+
192+
| Option | Default | Meaning |
193+
| --- | --- | --- |
194+
| `batchInterval` | 2s | Automatic flush interval |
195+
| `maxBatchSize` | unset | Flush once the buffer reaches this size |
196+
| `maxRetryDuration` | 10m | Max time to retry a failed flush before `FlushTimeoutException`. Must be < the workflow's publisher TTL (15m) to preserve exactly-once delivery |
197+
| `payloadConverters` | standard set | Per-item serialization. Payload conversion only — the client's codec chain runs once on the envelope, never per item |
198+
| `pollExecutor` | 2 daemon threads, client-owned | Scheduler shared by the client's subscriptions. It runs the short update-admission and delivery steps and poll cooldowns — never held during the long poll itself. A user-supplied executor is never shut down by the client; supply a bigger pool for many subscriptions against slow workflows |
199+
| `SubscribeOptions.pollCooldown` | 100ms | Min interval between polls |
200+
201+
## Cross-language protocol
202+
203+
The handler names (`WorkflowStreamConstants.PUBLISH_SIGNAL_NAME`,
204+
`POLL_UPDATE_NAME`, `OFFSET_QUERY_NAME`), the JSON envelope field names, and
205+
the per-item payload encoding (base64 of the serialized
206+
`temporal.api.common.v1.Payload`) match other languages' packages
207+
exactly, so a Java publisher or subscriber interoperates with a workflow
208+
written in any of them and vice versa. The data converter codec chain
209+
(encryption, compression) runs once on the signal/update envelope — never per
210+
item — so payloads are not double-encoded.
211+
212+
One Java-specific caveat: the protocol envelope types are serialized by the
213+
workflow's and client's *configured* data converter. The default Jackson JSON
214+
converter produces the wire-compatible snake_case field names (the types are
215+
annotated with `@JsonProperty`); if you configure a non-Jackson JSON converter,
216+
it must produce the same field names for cross-language interop.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
description = '''Temporal Workflow Streams: a durable, multi-topic pub/sub log hosted inside a workflow'''
2+
3+
dependencies {
4+
// this module shouldn't carry temporal-sdk with it, especially for situations when users may be using a shaded artifact
5+
compileOnly project(':temporal-sdk')
6+
// Jackson annotations lock the cross-SDK wire field names; provided at runtime by temporal-sdk
7+
compileOnly "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
8+
9+
testImplementation project(':temporal-sdk')
10+
testImplementation project(':temporal-testing')
11+
testImplementation "junit:junit:${junitVersion}"
12+
13+
testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}"
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.temporal.workflowstreams;
2+
3+
import io.temporal.common.Experimental;
4+
5+
/**
6+
* Thrown when a flush retry exceeds the client's max retry duration. The pending batch is dropped;
7+
* if the signal had already been delivered the items are in the log, otherwise they are lost.
8+
*/
9+
@Experimental
10+
public final class FlushTimeoutException extends RuntimeException {
11+
public FlushTimeoutException(String message) {
12+
super(message);
13+
}
14+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.temporal.workflowstreams;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import io.temporal.common.Experimental;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
/**
9+
* The poll update payload: a request to long-poll for new items.
10+
*
11+
* <p>Field names are part of the cross-language wire protocol; this type must serialize to JSON
12+
* with exactly these names.
13+
*/
14+
@Experimental
15+
public final class PollInput {
16+
/** Topics to filter on. Empty means all topics. */
17+
@JsonProperty("topics")
18+
public List<String> topics = new ArrayList<>();
19+
20+
/** Global offset to start from. Zero means the beginning. */
21+
@JsonProperty("from_offset")
22+
public long fromOffset;
23+
24+
public PollInput() {}
25+
26+
public PollInput(List<String> topics, long fromOffset) {
27+
this.topics = topics;
28+
this.fromOffset = fromOffset;
29+
}
30+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.temporal.workflowstreams;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import io.temporal.common.Experimental;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
/**
9+
* The poll update response: items matching the poll request. When {@code more_ready} is true the
10+
* response was truncated to stay within size limits and the subscriber should poll again
11+
* immediately rather than applying a cooldown.
12+
*
13+
* <p>Field names are part of the cross-language wire protocol; this type must serialize to JSON
14+
* with exactly these names.
15+
*/
16+
@Experimental
17+
public final class PollResult {
18+
@JsonProperty("items")
19+
public List<WireItem> items = new ArrayList<>();
20+
21+
@JsonProperty("next_offset")
22+
public long nextOffset;
23+
24+
@JsonProperty("more_ready")
25+
public boolean moreReady;
26+
27+
public PollResult() {}
28+
29+
public PollResult(List<WireItem> items, long nextOffset, boolean moreReady) {
30+
this.items = items;
31+
this.nextOffset = nextOffset;
32+
this.moreReady = moreReady;
33+
}
34+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.temporal.workflowstreams;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import io.temporal.common.Experimental;
5+
6+
/**
7+
* A single entry within a publish batch on the wire. {@code data} is a base64-encoded, serialized
8+
* {@link io.temporal.api.common.v1.Payload}.
9+
*
10+
* <p>Field names are part of the cross-language wire protocol; this type must serialize to JSON
11+
* with exactly these names.
12+
*/
13+
@Experimental
14+
public final class PublishEntry {
15+
@JsonProperty("topic")
16+
public String topic;
17+
18+
@JsonProperty("data")
19+
public String data;
20+
21+
public PublishEntry() {}
22+
23+
public PublishEntry(String topic, String data) {
24+
this.topic = topic;
25+
this.data = data;
26+
}
27+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.temporal.workflowstreams;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import io.temporal.common.Experimental;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
/**
9+
* The publish signal payload carrying a batch of entries, along with the dedup fields.
10+
*
11+
* <p>Field names are part of the cross-language wire protocol; this type must serialize to JSON
12+
* with exactly these names.
13+
*/
14+
@Experimental
15+
public final class PublishInput {
16+
@JsonProperty("items")
17+
public List<PublishEntry> items = new ArrayList<>();
18+
19+
@JsonProperty("publisher_id")
20+
public String publisherId = "";
21+
22+
@JsonProperty("sequence")
23+
public long sequence;
24+
25+
public PublishInput() {}
26+
27+
public PublishInput(List<PublishEntry> items, String publisherId, long sequence) {
28+
this.items = items;
29+
this.publisherId = publisherId;
30+
this.sequence = sequence;
31+
}
32+
}

0 commit comments

Comments
 (0)