Skip to content

Workflow streams user publish executor - #3025

Open
diwu-sf wants to merge 6 commits into
temporalio:mainfrom
diwu-sf:workflow-streams-user-publish-executor
Open

Workflow streams user publish executor#3025
diwu-sf wants to merge 6 commits into
temporalio:mainfrom
diwu-sf:workflow-streams-user-publish-executor

Conversation

@diwu-sf

@diwu-sf diwu-sf commented Aug 20, 2026

Copy link
Copy Markdown

What was changed

Every StreamPublisher instance was creating its own single thread executor to run the background flush. If the worker has high concurrency and many publishers, more and more platform threads will be created (and these mostly just idle).

When using virtual threads, it's also not possible to override this executor with a shared one that uses virtual threads. Follow the pattern from workflow stream client and allow configuring a shared publish executor.

Why?

To reduce the number of idle platform threads created for each stream publisher instance.

Checklist

  1. Closes
  1. How was this tested:

Tests added

  1. Any docs updates needed?

Doc updated

@diwu-sf
diwu-sf requested review from a team as code owners August 20, 2026 22:45
@CLAassistant

CLAassistant commented Aug 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

toStop = ownedSchedulerLocked();
}
if (toCancel != null) {
toCancel.cancel(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a background flush times out, this only cancels the periodic future when a user executor is supplied. Because the shared executor stays live, a later forceFlush=true or maxBatchSize trigger still submits backgroundFlush and sends the next buffered batch before throwDeferred surfaces the original error. I reproduced this with a user executor: let the first batch time out, clear the signal failure, then publish a second item with forceFlush=true; the second item is delivered. This also contradicts the new test comment that later items stay buffered until explicit flush or close. Please gate all triggered background flushes once deferredError is set, without shutting down the caller-owned executor, or otherwise surface the deferred error before another background send.

@brianstrauch brianstrauch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking on the publish-executor timeout behavior described in the inline comment. A caller-owned executor remains able to run forced or size-triggered background flushes after a deferred FlushTimeoutException, allowing later data to be sent before the failure is surfaced.

diwu-sf and others added 6 commits August 25, 2026 13:51
StreamPublisher always created its own native platform thread executor
to drive the background flush loop, one thread per publisher, with no
way to share an executor across publishers. Applications that want to
run flushes on virtual threads or a shared pool had no way to opt in.

Add a constructor that accepts a ScheduledExecutorService. When one is
supplied, the publisher never shuts it down: it only cancels the
periodic flush task on close or on a deferred flush timeout, leaving
the executor free for its other work. The default (no executor)
behavior is unchanged: a lazily created single-thread executor owned
and shut down by the publisher.
Every WorkflowStreamClient paid a dedicated platform thread for its
publisher's flush loop, with no way to share an executor across clients
even though WorkflowStreamClientOptions already allows one for the poll
path. Applications that want virtual threads or one shared pool for
many clients had no way to opt in.

Add setPublishExecutor, mirroring setPollExecutor: the supplied executor
drives the background flushes and the client never shuts it down — on
close it only stops its own tasks. Default behavior is unchanged.
Document the option in the module README and cover it with an
integration test sharing one executor across two clients.
Mentioning virtual threads ties the docs to the newest JDKs even though
any shared ScheduledExecutorService works. Describe the option in terms
of sharing one executor across clients instead.
Cancelling the periodic task only stopped one of the two ways a background
send starts: publish() still submitted backgroundFlush onto the executor on
forceFlush or a full buffer. Previously the executor shutdown closed both
paths, but a caller-owned executor must stay alive, so a triggered flush
shipped the next batch before flush()/close() surfaced the deferred
FlushTimeoutException.

Track the stop in a loopStopped flag set with deferredError, checked in
publish()'s trigger decision and at the top of backgroundFlush() so a task
queued before the stop cannot send either. Gating on deferredError itself is
not enough: throwDeferred() clears it, and the next triggered publish would
resubmit — onto an already-shut-down executor in the client-owned case, which
is where that path leaked RejectedExecutionException out of publish(). Both
executor modes now behave the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups to the flush-timeout gating:

- WorkflowStreamClient.close() ran publisher.close() outside try/finally, so
  the FlushTimeoutException its javadoc documents skipped subscription teardown
  and the owned poll executor shutdown, leaving pollers running forever.
- backgroundFlush() checked loopStopped but not closed. A triggered flush
  already queued on a shared user executor could still signal after close()
  returned; shutdownNow() used to make that impossible.
- ensureStartedLocked() left scheduleWithFixedDelay unguarded while the
  execute() below it was wrapped, so a user executor shut down by its owner
  threw RejectedExecutionException out of publish() after latching started.
- The README implied one flush() call both drains and rethrows; flush() throws
  before sending anything, close() drains first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The try/finally left three levels of teardown inline. Pull the driver loop and
the poll executor shutdown into named methods so close() reads as its two
steps; the executor block is moved, not changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@diwu-sf
diwu-sf force-pushed the workflow-streams-user-publish-executor branch from 855dd2c to 32e3d8e Compare August 25, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants