Skip to content

Commit 0fba0ec

Browse files
committed
Add wrap-the-lifetime hooks on client and worker plugins
`ClientPlugin.connectClient` wraps both `TemporalClient.connect` and the `ServiceLifecycle` `run()`. `WorkerPlugin.runWorker` wraps `TemporalWorker.run`, and `WorkerPlugin.runReplayer` wraps each `replayWorkflow` call. Defaults forward to `next`, so existing plugins are unaffected. The chain helpers recurse over the plugin array rather than folding with `reduce`, which keeps the wrapped body non-`@escaping` and preserves the isolation semantics of `withGRPCClient`. First plugin in the array is the outermost wrap, matching Ruby and C#. `SimplePlugin` exposes the three wraps as optional `@Sendable` before/after closures, so authors can bracket the connect, worker-run, or replay lifetimes without a custom `ClientPlugin`/`WorkerPlugin` conformance. Matches Ruby's `connect_client`/`run_worker`/`with_workflow_replay_worker` hooks on `SimplePlugin` and C#'s `SimplePlugin` virtual lifecycle methods. Closes #111.
1 parent 3d14608 commit 0fba0ec

12 files changed

Lines changed: 785 additions & 15 deletions

File tree

Sources/Temporal/Client/Plugins/ClientPlugin.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@
4040
/// An interceptor wraps each outbound call at runtime, while a plugin runs once at configuration
4141
/// time. A plugin can contribute interceptors by appending them to
4242
/// ``TemporalClient/Configuration/interceptors`` during ``configure(_:)``.
43+
///
44+
/// ## Wrapping the connected client lifetime
45+
///
46+
/// A plugin can also observe and surround the lifetime of a connected client by overriding
47+
/// ``connectClient(configuration:next:)``. The method receives the merged configuration and a
48+
/// continuation closure, and is expected to invoke the continuation as part of its body. Wrapping
49+
/// is useful for emitting telemetry around connect/disconnect, mapping errors, or running setup
50+
/// and teardown that should bracket the client's connected state. When plugins compose, the first
51+
/// plugin in ``TemporalClient/Configuration/plugins`` is the outermost wrap.
4352
public protocol ClientPlugin: Sendable {
4453
/// A human-readable name used in diagnostics and ordering.
4554
///
@@ -57,6 +66,38 @@ public protocol ClientPlugin: Sendable {
5766
///
5867
/// - Parameter configuration: The configuration to customize.
5968
func configure(_ configuration: inout TemporalClient.Configuration)
69+
70+
/// Wraps the lifetime of a connected client.
71+
///
72+
/// The default implementation forwards through to `next`, so plugins that do not need to
73+
/// surround the connected lifetime can omit this method. Override to bracket the connected
74+
/// client with custom logic, such as logging, metrics, or error mapping. Implementations must
75+
/// invoke `next` exactly once and return whatever value it produces, so the value returned by
76+
/// the public connect API is preserved. Calling `next` zero times or more than once is
77+
/// undefined behavior; the SDK reserves the right to detect and reject it in a future release.
78+
///
79+
/// When the configuration carries multiple plugins, the first plugin in
80+
/// ``TemporalClient/Configuration/plugins`` is the outermost wrap and the last plugin is the
81+
/// innermost. The continuation closure invokes the next plugin in the chain, and ultimately
82+
/// the gRPC connection plus the body supplied to the public connect entry point.
83+
///
84+
/// The wrap scope differs between the two public client entry points:
85+
/// - ``TemporalClient/connect(transport:configuration:logger:_:)`` brackets the entire gRPC
86+
/// client lifetime, including transport open and close.
87+
/// - For the two-phase ``TemporalClient`` `Service` API (init then `run()`), the wrap brackets
88+
/// the `run()` call only — the gRPC client is constructed by the init step before any plugin
89+
/// sees it.
90+
///
91+
/// - Parameters:
92+
/// - configuration: The merged configuration the client is about to use. Plugins receive
93+
/// the configuration after every plugin's ``configure(_:)`` has run.
94+
/// - next: A continuation that, when invoked, runs the rest of the plugin chain and the
95+
/// underlying connected work. Returns the value produced by the public connect entry point.
96+
/// - Returns: The value produced by `next`, optionally transformed by the plugin.
97+
func connectClient<R: Sendable>(
98+
configuration: TemporalClient.Configuration,
99+
next: () async throws -> sending R
100+
) async throws -> sending R
60101
}
61102

62103
extension ClientPlugin {
@@ -65,4 +106,11 @@ extension ClientPlugin {
65106
}
66107

67108
public func configure(_ configuration: inout TemporalClient.Configuration) {}
109+
110+
public func connectClient<R: Sendable>(
111+
configuration: TemporalClient.Configuration,
112+
next: () async throws -> sending R
113+
) async throws -> sending R {
114+
try await next()
115+
}
68116
}

Sources/Temporal/Client/Plugins/TemporalClient+Plugins.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,26 @@ extension TemporalClient.Configuration {
3939
}
4040
}
4141
}
42+
43+
/// Runs `body` wrapped by each plugin's ``ClientPlugin/connectClient(configuration:next:)``.
44+
///
45+
/// The first plugin in `plugins` is the outermost wrap and the last plugin is the innermost.
46+
/// When `plugins` is empty, `body` runs directly. The helper is recursive so that `body` can be
47+
/// passed through as a non-escaping closure, which lets callers forward a non-`@escaping` body
48+
/// parameter from a public connect entry point.
49+
package func applyClientConnectChain<R: Sendable>(
50+
plugins: ArraySlice<any ClientPlugin>,
51+
configuration: TemporalClient.Configuration,
52+
body: () async throws -> sending R
53+
) async throws -> sending R {
54+
guard let plugin = plugins.first else {
55+
return try await body()
56+
}
57+
return try await plugin.connectClient(configuration: configuration) {
58+
try await applyClientConnectChain(
59+
plugins: plugins.dropFirst(),
60+
configuration: configuration,
61+
body: body
62+
)
63+
}
64+
}

Sources/Temporal/Client/TemporalClient+ServiceLifecycle.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,20 @@ extension TemporalClient: Service {
5050
/// Starts the Temporal client and keeps it running until shutdown is initiated.
5151
///
5252
/// This method starts the underlying gRPC client and keeps it running to handle requests.
53+
/// Plugins set on ``Configuration/plugins`` wrap the run via
54+
/// ``ClientPlugin/connectClient(configuration:next:)``, with the first plugin in the array as
55+
/// the outermost wrap.
5356
///
5457
/// - Throws: A runtime error if the client cannot be started or is in an invalid state.
5558
public func run() async throws {
5659
// Graceful shutdown is handled by `GRPCServiceLifecycle`
57-
try await self.workflowService.client.client.run()
60+
let configuration = self.configuration
61+
try await applyClientConnectChain(
62+
plugins: configuration.plugins[...],
63+
configuration: configuration
64+
) {
65+
try await self.workflowService.client.client.run()
66+
}
5867
}
5968

6069
/// Initiates graceful shutdown of the Temporal client.

Sources/Temporal/Client/TemporalClient.swift

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,19 @@ public final class TemporalClient: Sendable {
173173
) async throws -> sending Result {
174174
var configuration = configuration
175175
configuration.applyPlugins(logger: logger)
176-
return try await withGRPCClient(
177-
transport: transport,
178-
interceptors: Self.grpcClientInterceptors(serverHostname: configuration.instrumentation.serverHostname, logger: logger),
179-
isolation: #isolation
180-
) { client in
181-
let temporalClient = Self.init(client: client, configuration: configuration)
182-
return try await body(temporalClient)
176+
let plugins = configuration.plugins
177+
return try await applyClientConnectChain(
178+
plugins: plugins[...],
179+
configuration: configuration
180+
) {
181+
try await withGRPCClient(
182+
transport: transport,
183+
interceptors: Self.grpcClientInterceptors(serverHostname: configuration.instrumentation.serverHostname, logger: logger),
184+
isolation: #isolation
185+
) { client in
186+
let temporalClient = Self.init(client: client, configuration: configuration)
187+
return try await body(temporalClient)
188+
}
183189
}
184190
}
185191
}

Sources/Temporal/Documentation.docc/Temporal.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ Configure workers and clients for different environments.
7171
- ``TemporalWorker/Configuration``
7272
- ``TemporalClient/Configuration``
7373

74+
### Extending with plugins
75+
76+
Customize clients, workers, and replayers with reusable plugins.
77+
78+
- ``ClientPlugin``
79+
- ``WorkerPlugin``
80+
- ``SimplePlugin``
81+
7482
## See Also
7583

7684
- [Temporal Documentation - Complete platform documentation](https://docs.temporal.io)

Sources/Temporal/Plugins/SimplePlugin.swift

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@
4949
/// dataConverter: { existing in EncryptingDataConverter(wrapping: existing) }
5050
/// )
5151
/// ```
52+
///
53+
/// ## Bracketing the connect, run, and replay lifetimes
54+
///
55+
/// To run setup or teardown around the connected-client, worker-run, or replay lifetimes, pass the
56+
/// matching `before`/`after` closures. The before closure runs immediately before the wrapped
57+
/// work, the after closure immediately after it returns successfully; if the wrapped work throws,
58+
/// the after closure does not run. If the before closure itself throws, the error propagates and
59+
/// neither the wrapped work nor the after closure runs. For full wrap semantics, such as
60+
/// short-circuiting or mapping errors, conform to ``ClientPlugin`` or ``WorkerPlugin`` directly
61+
/// and override the `connectClient(configuration:next:)`, `runWorker(configuration:next:)`, or
62+
/// `runReplayer(configuration:next:)` method.
5263
public struct SimplePlugin: ClientPlugin, WorkerPlugin {
5364
/// A human-readable name used in diagnostics and ordering.
5465
public let name: String
@@ -68,6 +79,30 @@ public struct SimplePlugin: ClientPlugin, WorkerPlugin {
6879
/// Workflows the plugin registers with the worker and replayer.
6980
public let workflows: [any WorkflowDefinition.Type]
7081

82+
/// A closure invoked immediately before the inner work inside ``connectClient(configuration:next:)``.
83+
public let beforeConnect: (@Sendable (TemporalClient.Configuration) async throws -> Void)?
84+
85+
/// A closure invoked immediately after the inner work inside ``connectClient(configuration:next:)``.
86+
///
87+
/// Does not run if the inner work throws.
88+
public let afterConnect: (@Sendable (TemporalClient.Configuration) async throws -> Void)?
89+
90+
/// A closure invoked immediately before the inner work inside ``runWorker(configuration:next:)``.
91+
public let beforeRunWorker: (@Sendable (TemporalWorker.Configuration) async throws -> Void)?
92+
93+
/// A closure invoked immediately after the inner work inside ``runWorker(configuration:next:)``.
94+
///
95+
/// Does not run if the inner work throws.
96+
public let afterRunWorker: (@Sendable (TemporalWorker.Configuration) async throws -> Void)?
97+
98+
/// A closure invoked immediately before the inner work inside ``runReplayer(configuration:next:)``.
99+
public let beforeRunReplayer: (@Sendable (WorkflowReplayer.Configuration) async throws -> Void)?
100+
101+
/// A closure invoked immediately after the inner work inside ``runReplayer(configuration:next:)``.
102+
///
103+
/// Does not run if the inner work throws.
104+
public let afterRunReplayer: (@Sendable (WorkflowReplayer.Configuration) async throws -> Void)?
105+
71106
/// Creates a new simple plugin.
72107
///
73108
/// - Parameters:
@@ -80,20 +115,41 @@ public struct SimplePlugin: ClientPlugin, WorkerPlugin {
80115
/// Defaults to empty.
81116
/// - activities: Activities registered with the worker. Defaults to empty.
82117
/// - workflows: Workflows registered with the worker and replayer. Defaults to empty.
118+
/// - beforeConnect: An optional closure run immediately before the connected-client work.
119+
/// - afterConnect: An optional closure run immediately after the connected-client work
120+
/// returns successfully.
121+
/// - beforeRunWorker: An optional closure run immediately before the worker's run loop.
122+
/// - afterRunWorker: An optional closure run immediately after the worker's run loop returns
123+
/// successfully.
124+
/// - beforeRunReplayer: An optional closure run immediately before a single replay.
125+
/// - afterRunReplayer: An optional closure run immediately after a single replay returns
126+
/// successfully.
83127
public init(
84128
name: String,
85129
dataConverter: (@Sendable (DataConverter) -> DataConverter)? = nil,
86130
clientInterceptors: [any ClientInterceptor] = [],
87131
workerInterceptors: [any WorkerInterceptor] = [],
88132
activities: [any ActivityDefinition] = [],
89-
workflows: [any WorkflowDefinition.Type] = []
133+
workflows: [any WorkflowDefinition.Type] = [],
134+
beforeConnect: (@Sendable (TemporalClient.Configuration) async throws -> Void)? = nil,
135+
afterConnect: (@Sendable (TemporalClient.Configuration) async throws -> Void)? = nil,
136+
beforeRunWorker: (@Sendable (TemporalWorker.Configuration) async throws -> Void)? = nil,
137+
afterRunWorker: (@Sendable (TemporalWorker.Configuration) async throws -> Void)? = nil,
138+
beforeRunReplayer: (@Sendable (WorkflowReplayer.Configuration) async throws -> Void)? = nil,
139+
afterRunReplayer: (@Sendable (WorkflowReplayer.Configuration) async throws -> Void)? = nil
90140
) {
91141
self.name = name
92142
self.dataConverter = dataConverter
93143
self.clientInterceptors = clientInterceptors
94144
self.workerInterceptors = workerInterceptors
95145
self.activities = activities
96146
self.workflows = workflows
147+
self.beforeConnect = beforeConnect
148+
self.afterConnect = afterConnect
149+
self.beforeRunWorker = beforeRunWorker
150+
self.afterRunWorker = afterRunWorker
151+
self.beforeRunReplayer = beforeRunReplayer
152+
self.afterRunReplayer = afterRunReplayer
97153
}
98154

99155
public func configure(_ configuration: inout TemporalClient.Configuration) {
@@ -116,4 +172,33 @@ public struct SimplePlugin: ClientPlugin, WorkerPlugin {
116172
}
117173
configuration.interceptors.append(contentsOf: workerInterceptors)
118174
}
175+
176+
public func connectClient<R: Sendable>(
177+
configuration: TemporalClient.Configuration,
178+
next: () async throws -> sending R
179+
) async throws -> sending R {
180+
try await beforeConnect?(configuration)
181+
let result = try await next()
182+
try await afterConnect?(configuration)
183+
return result
184+
}
185+
186+
public func runWorker(
187+
configuration: TemporalWorker.Configuration,
188+
next: () async throws -> Void
189+
) async throws {
190+
try await beforeRunWorker?(configuration)
191+
try await next()
192+
try await afterRunWorker?(configuration)
193+
}
194+
195+
public func runReplayer<R: Sendable>(
196+
configuration: WorkflowReplayer.Configuration,
197+
next: () async throws -> sending R
198+
) async throws -> sending R {
199+
try await beforeRunReplayer?(configuration)
200+
let result = try await next()
201+
try await afterRunReplayer?(configuration)
202+
return result
203+
}
119204
}

Sources/Temporal/Replayer/WorkflowReplayer.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ public final class WorkflowReplayer: Sendable {
6262

6363
/// Replays a single workflow history.
6464
///
65+
/// Plugins set on ``Configuration/plugins`` wrap each replay invocation via
66+
/// ``WorkerPlugin/runReplayer(configuration:next:)``, with the first plugin in the array as the
67+
/// outermost wrap.
68+
///
6569
/// - Parameters:
6670
/// - history: The workflow history to replay.
6771
/// - throwOnReplayFailure: If `true` (the default), throws an error when replay
@@ -75,15 +79,25 @@ public final class WorkflowReplayer: Sendable {
7579
history: WorkflowHistory,
7680
throwOnReplayFailure: Bool = true
7781
) async throws -> WorkflowReplayResult {
78-
let runner = try WorkflowHistoryRunner(configuration: self.configuration)
79-
return try await runner.replayWorkflow(
80-
history: history,
81-
throwOnReplayFailure: throwOnReplayFailure
82-
)
82+
try await applyReplayerRunChain(
83+
plugins: self.configuration.plugins[...],
84+
configuration: self.configuration
85+
) {
86+
let runner = try WorkflowHistoryRunner(configuration: self.configuration)
87+
return try await runner.replayWorkflow(
88+
history: history,
89+
throwOnReplayFailure: throwOnReplayFailure
90+
)
91+
}
8392
}
8493

8594
/// Replays multiple workflow histories.
8695
///
96+
/// Each history is replayed by delegating to ``replayWorkflow(history:throwOnReplayFailure:)``,
97+
/// so the plugin chain wraps each replay individually rather than the batch as a whole. A
98+
/// plugin that records latency in ``WorkerPlugin/runReplayer(configuration:next:)`` therefore
99+
/// observes one sample per history.
100+
///
87101
/// - Parameters:
88102
/// - histories: The workflow histories to replay.
89103
/// - throwOnReplayFailure: If `true` (the default), throws on the first replay

Sources/Temporal/Worker/Plugins/TemporalWorker+Plugins.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,46 @@ extension WorkflowReplayer.Configuration {
7272
self.workflows += plugins.flatMap(\.workflows)
7373
}
7474
}
75+
76+
/// Runs `body` wrapped by each plugin's ``WorkerPlugin/runWorker(configuration:next:)``.
77+
///
78+
/// The first plugin in `plugins` is the outermost wrap and the last plugin is the innermost.
79+
/// When `plugins` is empty, `body` runs directly.
80+
package func applyWorkerRunChain(
81+
plugins: ArraySlice<any WorkerPlugin>,
82+
configuration: TemporalWorker.Configuration,
83+
body: () async throws -> Void
84+
) async throws {
85+
guard let plugin = plugins.first else {
86+
try await body()
87+
return
88+
}
89+
try await plugin.runWorker(configuration: configuration) {
90+
try await applyWorkerRunChain(
91+
plugins: plugins.dropFirst(),
92+
configuration: configuration,
93+
body: body
94+
)
95+
}
96+
}
97+
98+
/// Runs `body` wrapped by each plugin's ``WorkerPlugin/runReplayer(configuration:next:)``.
99+
///
100+
/// The first plugin in `plugins` is the outermost wrap and the last plugin is the innermost.
101+
/// When `plugins` is empty, `body` runs directly.
102+
package func applyReplayerRunChain<R: Sendable>(
103+
plugins: ArraySlice<any WorkerPlugin>,
104+
configuration: WorkflowReplayer.Configuration,
105+
body: () async throws -> sending R
106+
) async throws -> sending R {
107+
guard let plugin = plugins.first else {
108+
return try await body()
109+
}
110+
return try await plugin.runReplayer(configuration: configuration) {
111+
try await applyReplayerRunChain(
112+
plugins: plugins.dropFirst(),
113+
configuration: configuration,
114+
body: body
115+
)
116+
}
117+
}

0 commit comments

Comments
 (0)