Skip to content

feat(sdk): Add plugins - #1436

Merged
chris-olszewski merged 14 commits into
mainfrom
olszewski/feat_plugins
Aug 6, 2026
Merged

feat(sdk): Add plugins#1436
chris-olszewski merged 14 commits into
mainfrom
olszewski/feat_plugins

Conversation

@chris-olszewski

@chris-olszewski chris-olszewski commented Aug 1, 2026

Copy link
Copy Markdown
Member

What was changed

Prefactors

  • Added Debug implementations to Client/Worker
  • Moving all interceptors to be registered via WorkerOptions instead of directly on the Worker. Allow multiple WorkerInterceptors per Worker instead of only a single one. This is done in the first commit of the PR.

Main Changes

Added ClientPlugin and WorkerPlugin traits which allows users to implement plugins. The stored types are ErasedClientPlugin and ErasedWorkerPlugin. The worker plugin is just a named Arc<dyn WorkerPlugin>, but ErasedClientPlugin carries WorkerPluginData which is essentially a named Any that carries worker plugins. This is needed since the client crate doesn't have access to the types necessary to define the worker plugin trait. On the actual worker plugin consumption side, we only respect the private PropagatedWorkerPlugin.

Client/worker only plugins will be registered via ClientOptions::client_plugin(plugin: impl ClientPlugin)/WorkerOptions::worker_plugin(plugin: impl WorkerPlugin).

Dual plugins are registered usingClientAndWorkerPlugin struct which enabled propagating plugins that from a Client to any Worker that is constructed with that client. These plugins are used with ClientOptions::plugin. e.g.

struct MyPlugin;
impl ClientPlugin for MyPlugin {..}
impl WorkerPlugin for MyPlugin {..}
let plugin = ClientAndWorkerPlugin::new(MyPlugin);
let client_options = ClientOptions::new("default").plugin(plugin).build();
let client = Client::connect(.., client_options)?;
// Worker will be configured with the worker plugin
let worker = Worker::new(client, ...)?;

Why?

SDK Parity

I do not love the special type for dual plugins, but I didn't find a better solution that didn't encourage misuse.

We could get rid of the special client_plugin/worker_plugin constructors and force users to build the erased types themselves if we want to remove some surface area here. We could offer blanket implementation conversion for any plugin implementers into the erased type, but that opens the door for misuse of dual plugins e.g.

// Will not propagate worker plugins
ClientOptions::new(..).plugin(MyPlugin).build();
// Will propagate worker plugins
ClientOptions::new(..).plugin(ClientAndWorkerPlugin::new(MyPlugin)).build();

Checklist

  1. Closes [Feature Request] Support Plugins #1356

  2. How was this tested:
    Added unit tests around error propagation. Very basic integration tests just verifying plugin fields are respected.

  3. Any docs updates needed?
    Plugin docs can now be updated to include Rust samples once this is released.

Comment thread crates/sdk/src/interceptors.rs
@chris-olszewski
chris-olszewski force-pushed the olszewski/feat_plugins branch from 213835b to 65d811d Compare August 1, 2026 17:07
@chris-olszewski
chris-olszewski marked this pull request as ready for review August 1, 2026 17:08
@chris-olszewski
chris-olszewski requested a review from a team as a code owner August 1, 2026 17:08
Comment thread crates/sdk/src/interceptors.rs
Comment thread crates/sdk/src/lib.rs Outdated
Comment on lines +195 to +196
#[builder(field)]
worker_plugins: Vec<ErasedWorkerPlugin>,

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.

Does this make sense here? Would it be possible to have a worker plugin add itself to the worker options and infinite loop? 😆

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.

It is private so the only way that a plugin could mess with the list would be to rebuild from scratch via *options = WorkerOptions::new(...).worker_plugins(vec![AnotherPlugin]).build().

Even if a user did that we wouldn't recurse since we take the plugin list before we start applying it. Added a unit test verifying this along with a warning comment.

@Sushisource Sushisource Aug 4, 2026

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.

Well I was less concerned about the infinite loop thing and more just wondering why they actually need to get stored here. Doesn't the plugin apply the options stuff earlier on via the client? Or I guess there's another worker-only path that I probably just didn't quite grok in my review

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.

Yeah, we still allow for worker plugins to be explicitly configured on the worker directly instead of the client. Could force everything through the client and remove this though if that's desired.

Comment thread crates/sdk/src/plugins.rs Outdated
Comment thread crates/sdk/src/plugins.rs
///
/// **Experimental:** This API may change or be removed.
#[derive(Clone)]
pub struct ClientAndWorkerPlugin {

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.

Yeah I think in theory people could avoid needing to know about this directly by impl

impl From<T> for ClientAndWorkerPlugin where T: ClientPlugin + WorkerPlugin

And then accept anything that Into<ClientAndWorkerPlugin>

Or, maybe better, do

trait ClientAndWorkerPlugin { ... }; impl<T> ClientAndWorkerPlugin for T where T: ClientPlugin + WorkerPlugin {...}

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.

What would the be the trait bounds in the client crate for accepting a ClientAndWorkerPlugin? The trait would need to be defined in the client crate, but the blanket impl needs to be in the SDK so we have access to WorkerPlugin, but won't be able to because of the ophan rule.

We could do this via a trait extension defined in the SDK crate that adds a combined_plugin<ClientPlugin + WorkerPlugin> to client options? Still some concern about user misuse of passing a combined plugin to client_plugin not realizing the woker half of the plugin is getting dropped.

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.

Ah, I see what you mean. Yeah, that being the case I can see why this makes sense.

Comment thread crates/sdk/src/plugins.rs Outdated

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

My last comment isn't blocking. Might be good to have @tconley1428 do a pass.

Comment thread crates/sdk/src/plugins.rs
///
/// Worker plugin registrations are captured before this method is called. Altering plugins in
/// this method does not change which plugins are applied.
fn configure_worker_options(&self, _options: &mut WorkerOptions) -> Result<(), PluginError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In other languages this also provides a mechanism for running around the worker lifetime

Comment thread crates/sdk/src/plugins.rs Outdated
#[builder(field)]
data_converter: Option<DataConverter>,
#[builder(field)]
client_interceptors: Vec<Arc<dyn ClientInterceptor>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have found separate client/worker interceptor lists to be tricky to use when you have an interceptor which does both (unless that's not a thing in rust) - instead I took both as a single list.

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.

A type could implement both. Hard thing is we would need to add a new enum with each variant having a different Arc<dyn *Interceptor> combination. End users would need to manually construct these as we couldn't provide conversions that would distinguish between types that are implement one interceptor or those that implement multiple.

Comment thread crates/sdk/src/plugins.rs
/// **Experimental:** This API may change or be removed.
#[derive(Clone, bon::Builder)]
#[builder(state_mod(vis = "pub"))]
pub struct SimplePlugin {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This will be more limited than other simple plugins, which allow you to see the previous value before setting using functions. That's especially common around data converter manipulation.

Comment thread crates/sdk/src/lib.rs
#[builder(field)]
workflows: WorkflowDefinitions,

#[builder(field)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't follow why adding plugins added new interceptor options here. How did worker interceptors work before?

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.

Previously they were registered by modifying an already constructed worker. First commit of this PR switched so all interceptors are set via the WorkerOptions instead of modifying already created workers.

Comment thread crates/sdk/src/lib.rs Outdated
Comment on lines +211 to +214
client_plugin_names: HashSet<String>,

#[builder(field)]
plugins_applied: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What are these things for? They sound like internal state, but this seems like a public configuration object.

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.

Was able to remove the worker one by being a little more careful around the worker lifecycle. Still need the client one as the client in activity context is constructed. This bit of state isn't visible to end users.

Comment thread crates/sdk/src/plugins.rs
/// Use [`ClientAndWorkerPlugin`] for plugins that target both clients and workers.
///
/// **Experimental:** This API may change or be removed.
pub trait WorkerPlugin: Send + Sync + 'static {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does the rust SDK have a replayer?

@chris-olszewski
chris-olszewski force-pushed the olszewski/feat_plugins branch from 09ba165 to 5b1913a Compare August 6, 2026 02:16
@chris-olszewski
chris-olszewski enabled auto-merge (squash) August 6, 2026 02:33
@chris-olszewski
chris-olszewski merged commit f192649 into main Aug 6, 2026
22 checks passed
@chris-olszewski
chris-olszewski deleted the olszewski/feat_plugins branch August 6, 2026 02:40
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] Support Plugins

3 participants