Skip to content

feat(sdk): workflow interceptors - #1401

Merged
chris-olszewski merged 23 commits into
mainfrom
olszewski/workflow_interceptors_2
Jul 30, 2026
Merged

chris-olszewski merged 23 commits into
mainfrom
olszewski/workflow_interceptors_2

Conversation

@chris-olszewski

@chris-olszewski chris-olszewski commented Jul 14, 2026

Copy link
Copy Markdown
Member

What was changed

  • Workflow implementation no longer handles the conversion of outputs to payloads and instead returns WorkflowOutputValue that allows interceptors to inspect/modify outputs before serialization.
  • continue_as_new no longer takes reference to input since interceptors might modify/swap the input. In retrospect taking a reference was also awkward IMO.
  • Add WorkflowInterceptor trait and update workflow machinery to use interceptors.
  • add_workflow_interceptor_factory/WorkflowInterceptorFactory which constructs an named array of workflow interceptors.
  • WorkflowInterceptorFuture a named future that is the expected future to be produced by interceptor futures. We could expose LocalBoxFuture directly instead if we're willing to expose that 3rd party type as an external API type.

WorkflowInterceptor

Interceptors look like the following:

fn handle_signal<'a>(
        &'a self,
        ctx: WorkflowInterceptorContext,
        input: HandleSignalInput,
        next: WorkflowNext<
            'a,
            HandleSignalInput,
            WorkflowInterceptorFuture<'a, HandleSignalResult>,
        >,
    ) -> WorkflowInterceptorFuture<'a, HandleSignalResult> {
        next.run(input)
    }

They take a context with a subset of functionality of a workflow context, input for the operation and a next continuation that calls the next interceptor in the chain. They are always sync forcing users to do any async work in a WorkflowInterceptorFuture.

Why?

The changes to the workflow machinery and the "construction" polling are to allow the interceptor chains to not delay command creation/handler execution if there isn't any async work. This was especially apparent with sync handlers that were eagerly executed previously. Without this special handling, the existence of an interceptor chain would at least one yield point before the execution of the handler/command creation. The construction poll is our way of ensuring we drive the chain as forward as possible.

An alternative for this would be to make the interceptor API where Next would have some explicit .then methods to remove any yield points. I felt this diverged too much from all other SDKs and was generally pretty awkward to work with.

A few other decisions

  • We have a singular workflow interceptor trait over 2 separate ones to make registration a singular method instead of spliting inbound/outbound.
  • WASM interceptors have to be defined inside the component

Checklist

  1. Closes [Feature Request] Workflow interceptors #1139

  2. How was this tested:
    See integration tests.

  3. Any docs updates needed?
    Once released should add interceptors page for the Rust SDK docs.

@chris-olszewski
chris-olszewski force-pushed the olszewski/workflow_interceptors_2 branch from 7b18607 to 358c765 Compare July 14, 2026 18:53
@chris-olszewski
chris-olszewski marked this pull request as ready for review July 17, 2026 13:44
@chris-olszewski
chris-olszewski requested a review from a team as a code owner July 17, 2026 13:44

@Sushisource Sushisource 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.

Woof that was a doozy

Comment thread crates/sdk/examples/wasm_workflows/src/lib.rs Outdated
Comment thread crates/workflow/src/runtime/instance.rs
Comment thread crates/workflow/src/runtime/mod.rs Outdated
Comment thread crates/workflow/src/workflow_context.rs Outdated
Comment thread crates/workflow/src/runtime/mod.rs Outdated
Comment thread crates/workflow/src/workflow_interceptors.rs Outdated
Comment thread crates/workflow/src/workflow_context.rs Outdated
impl ContinueAsNewInput {
pub(crate) fn new(input: Box<dyn Any>, options: ContinueAsNewOptions) -> Self {
Self {
decoded: DecodedInput::new(Some(input), HashMap::new()),

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.

How can user specify headers? Needs to be extracted from options

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed the headers field from continue as new opts so headers must be set in an interceptor now. Aligns with TS/.NET.

Comment thread crates/sdk/src/workflow_wasm.rs Outdated
) -> Result<Box<dyn WorkflowInstance>, anyhow::Error> {
if !input.workflow_interceptor_factories.is_empty() {
bail!("Native workflow interceptors cannot be used with WASM workflow components");
}

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.

This seems like the wrong place to fail - we will fail every time we try to execute a workflow defined in WASM rather than just failing on worker startup, which seemingly we could do and would be faster.

However, I'm not sure why this needs to be an error. If a user defines interceptors in the WASM bundle, I think we can use those and just document that native ones do not apply to WASM workflows?

I'm also not 100% clear on why native ones can't be applied to WASM workflows. I guess just extra back-and-forth glue? (To be clear, I don't object, just want to clarify).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

However, I'm not sure why this needs to be an error. If a user defines interceptors in the WASM bundle, I think we can use those and just document that native ones do not apply to WASM workflows?

You're right, no need to error in this scenario.

I guess just extra back-and-forth glue?

Correct, this has been in development for long enough and the PR is large enough that I didn't feel tackling this here was necessary.

Comment thread crates/workflow/src/workflow_context.rs Outdated
"--release",
"--target",
"wasm32-unknown-unknown",
"--target-dir",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not strictly necessary, but I have a custom target dir setup on my machine and allows me to run WASM tests locally without overriding it.

Comment thread crates/workflow/src/runtime/entry.rs Outdated
.boxed_local()
Ok(Box::new(output) as Box<dyn WorkflowOutputValue>)
};
ConstructionBlockedFuture::new(base_ctx, future.boxed_local()).boxed_local()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We want to ensure that if there is a sync handler with no interceptors, it is driven to completed before starting to poll an async handler.

impl ContinueAsNewInput {
pub(crate) fn new(input: Box<dyn Any>, options: ContinueAsNewOptions) -> Self {
Self {
decoded: DecodedInput::new(Some(input), HashMap::new()),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed the headers field from continue as new opts so headers must be set in an interceptor now. Aligns with TS/.NET.

Comment thread crates/workflow/src/workflow_context.rs Outdated

pub(crate) enum NexusResultFuture {
Raw(Shared<WFCommandFut<NexusOperationResult, ()>>),
Intercepted(Shared<LocalBoxFuture<'static, NexusOperationResult>>),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

On second thought I removed this entirely since the final shape of calling nexus operations hasn't stabilized.

}

/// Creates a fresh interceptor collection for each workflow instance.
pub trait WorkflowInterceptorFactory: Send + Sync + 'static {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If this took some param that provided context about the workflow

Meant to do that, but totally forgot which did make it pointless.

Updated to now take a vec of WorkflowInterceptorConstructors which are wrappers around Fn(&WorkflowContextView) -> WorkflowInterceptor. Only have a named type so end users don't need to deal with the boxing of closures and the constructed interceptor.

Comment thread crates/workflow/src/workflow_context.rs Outdated
};
ActivityFut::running(
LATimerBackoffFut::new(activity.name().to_string(), payloads, opts, self.clone()),
let future = LATimerBackoffFut::new(activity_type, payloads, headers, opts, self.clone());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Did a quick check of Ruby and TS it seems that a regular timer with interceptors is used in both cases. We could set a timer summary that users could check?

Comment thread crates/sdk/src/workflow_wasm.rs Outdated
) -> Result<Box<dyn WorkflowInstance>, anyhow::Error> {
if !input.workflow_interceptor_factories.is_empty() {
bail!("Native workflow interceptors cannot be used with WASM workflow components");
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

However, I'm not sure why this needs to be an error. If a user defines interceptors in the WASM bundle, I think we can use those and just document that native ones do not apply to WASM workflows?

You're right, no need to error in this scenario.

I guess just extra back-and-forth glue?

Correct, this has been in development for long enough and the PR is large enough that I didn't feel tackling this here was necessary.

@Sushisource Sushisource 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.

I find the complexity of the whole intercepted futures system to be a bit concerning but I don't have any good alternative suggestions off the top of my head

Comment thread crates/sdk/src/lib.rs
Comment thread crates/sdk/src/workflow_executor.rs
Comment thread crates/workflow/src/runtime/mod.rs
Comment thread crates/workflow/src/runtime/types.rs
@chris-olszewski
chris-olszewski force-pushed the olszewski/workflow_interceptors_2 branch from e492cce to c24f347 Compare July 29, 2026 21:11
@chris-olszewski
chris-olszewski merged commit fe9f753 into main Jul 30, 2026
38 of 40 checks passed
@chris-olszewski
chris-olszewski deleted the olszewski/workflow_interceptors_2 branch July 30, 2026 01:25
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.

[Feature Request] Workflow interceptors

2 participants