Skip to content

Commit 3dd6cca

Browse files
authored
Expose instance to workflows/activities and client to activities (#393)
1 parent 9673804 commit 3dd6cca

File tree

12 files changed

+378
-56
lines changed

12 files changed

+378
-56
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ jobs:
2222
- os: ubuntu-latest
2323
docsTarget: true
2424
cloudTestTarget: true
25+
# This is here alongside docsTarget because newer docfx doesn't work
26+
# with .NET 6.
27+
dotNetVersionOverride: |
28+
6.x
29+
8.x
2530
- os: ubuntu-arm
2631
runsOn: ubuntu-24.04-arm64-2-core
2732
- os: macos-intel
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Threading;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace Temporalio.Extensions.Hosting
5+
{
6+
/// <summary>
7+
/// Information and ability to control the activity DI scope.
8+
/// </summary>
9+
public static class ActivityScope
10+
{
11+
private static readonly AsyncLocal<IServiceScope?> ServiceScopeLocal = new();
12+
private static readonly AsyncLocal<object?> ScopedInstanceLocal = new();
13+
14+
/// <summary>
15+
/// Gets or sets the current scope for this activity.
16+
/// </summary>
17+
/// <remarks>
18+
/// This is backed by an async local. By default, when the activity invocation starts
19+
/// (meaning inside the interceptor, not before), a new service scope is created and set on
20+
/// this value. This means it will not be present in the primary execute-activity
21+
/// interceptor
22+
/// (<see cref="Worker.Interceptors.ActivityInboundInterceptor.ExecuteActivityAsync"/>) call
23+
/// but will be available everywhere else the ActivityExecutionContext is. When set by the
24+
/// internal code, it is also disposed by the internal code. See the next remark for how to
25+
/// control the scope.
26+
/// </remarks>
27+
/// <remarks>
28+
/// In situations where a user wants to control the service scope from the primary
29+
/// execute-activity interceptor, this can be set to the result of <c>CreateScope</c> or
30+
/// <c>CreateAsyncScope</c> of a service provider. The internal code will then use this
31+
/// instead of creating its own, and will therefore not dispose it. This should never be set
32+
/// anywhere but inside the primary execute-activity interceptor, and it no matter the value
33+
/// it will be set to null before the <c>base</c> call returns from the primary
34+
/// execute-activity interceptor.
35+
/// </remarks>
36+
public static IServiceScope? ServiceScope
37+
{
38+
get => ServiceScopeLocal.Value;
39+
set => ServiceScopeLocal.Value = value;
40+
}
41+
42+
/// <summary>
43+
/// Gets or sets the scoped instance for non-static activity methods.
44+
/// </summary>
45+
/// <remarks>
46+
/// This is backed by an async local. By default, when the activity invocation starts
47+
/// (meaning inside the interceptor, not before) for a non-static method, an instance is
48+
/// obtained from the service provider and set on this value. This means it will not be
49+
/// present in the primary execute-activity interceptor
50+
/// (<see cref="Worker.Interceptors.ActivityInboundInterceptor.ExecuteActivityAsync"/>) call
51+
/// but will be available everywhere else the ActivityExecutionContext is. See the next
52+
/// remark for how to control the instance.
53+
/// </remarks>
54+
/// <remarks>
55+
/// In situations where a user wants to control the instance from the primary
56+
/// execute-activity interceptor, this can be set to the result of <c>GetRequiredService</c>
57+
/// of a service provider. The internal code will then use this instead of creating its own.
58+
/// This should never be set anywhere but inside the primary execute-activity interceptor,
59+
/// and it no matter the value it will be set to null before the <c>base</c> call returns
60+
/// from the primary execute-activity interceptor.
61+
/// </remarks>
62+
public static object? ScopedInstance
63+
{
64+
get => ScopedInstanceLocal.Value;
65+
set => ScopedInstanceLocal.Value = value;
66+
}
67+
}
68+
}

src/Temporalio.Extensions.Hosting/ServiceProviderExtensions.cs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,31 @@ public static ActivityDefinition CreateTemporalActivityDefinition(
6262
// Invoker can be async (i.e. returns Task<object?>)
6363
async Task<object?> Invoker(object?[] args)
6464
{
65-
// Wrap in a scope (even for statics to keep logic simple)
65+
// Wrap in a scope if scope doesn't already exist. Keep track of whether we created
66+
// it so we can dispose of it.
67+
var scope = ActivityScope.ServiceScope;
68+
var createdScopeOurselves = scope == null;
69+
if (scope == null)
70+
{
6671
#if NET6_0_OR_GREATER
67-
var scope = provider.CreateAsyncScope();
72+
scope = provider.CreateAsyncScope();
6873
#else
69-
var scope = provider.CreateScope();
74+
scope = provider.CreateScope();
7075
#endif
76+
ActivityScope.ServiceScope = scope;
77+
}
78+
79+
// Run
7180
try
7281
{
7382
object? result;
7483
try
7584
{
76-
// Invoke static or non-static
85+
// Create the instance if not static and not already created
7786
var instance = method.IsStatic
7887
? null
79-
: scope.ServiceProvider.GetRequiredService(instanceType);
88+
: ActivityScope.ScopedInstance ?? scope.ServiceProvider.GetRequiredService(instanceType);
89+
ActivityScope.ScopedInstance = instance;
8090

8191
result = method.Invoke(instance, args);
8292
}
@@ -111,11 +121,24 @@ public static ActivityDefinition CreateTemporalActivityDefinition(
111121
}
112122
finally
113123
{
124+
// Dispose of scope if we created it
125+
if (createdScopeOurselves)
126+
{
114127
#if NET6_0_OR_GREATER
115-
await scope.DisposeAsync().ConfigureAwait(false);
128+
if (scope is AsyncServiceScope asyncScope)
129+
{
130+
await asyncScope.DisposeAsync().ConfigureAwait(false);
131+
}
132+
else
133+
{
134+
scope.Dispose();
135+
}
116136
#else
117-
scope.Dispose();
137+
scope.Dispose();
118138
#endif
139+
}
140+
ActivityScope.ServiceScope = null;
141+
ActivityScope.ScopedInstance = null;
119142
}
120143
}
121144
return ActivityDefinition.Create(method, Invoker);

src/Temporalio/Activities/ActivityExecutionContext.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Threading;
44
using Google.Protobuf;
55
using Microsoft.Extensions.Logging;
6+
using Temporalio.Client;
67
using Temporalio.Common;
78
using Temporalio.Converters;
89

@@ -16,6 +17,7 @@ namespace Temporalio.Activities
1617
public class ActivityExecutionContext
1718
{
1819
private readonly Lazy<MetricMeter> metricMeter;
20+
private readonly ITemporalClient? temporalClient;
1921

2022
/// <summary>
2123
/// Initializes a new instance of the <see cref="ActivityExecutionContext"/> class.
@@ -27,6 +29,7 @@ public class ActivityExecutionContext
2729
/// <param name="logger">Logger.</param>
2830
/// <param name="payloadConverter">Payload converter.</param>
2931
/// <param name="runtimeMetricMeter">Runtime-level metric meter.</param>
32+
/// <param name="temporalClient">Temporal client.</param>
3033
#pragma warning disable CA1068 // We don't require cancellation token as last param
3134
internal ActivityExecutionContext(
3235
ActivityInfo info,
@@ -35,7 +38,8 @@ internal ActivityExecutionContext(
3538
ByteString taskToken,
3639
ILogger logger,
3740
IPayloadConverter payloadConverter,
38-
Lazy<MetricMeter> runtimeMetricMeter)
41+
Lazy<MetricMeter> runtimeMetricMeter,
42+
ITemporalClient? temporalClient)
3943
{
4044
Info = info;
4145
CancellationToken = cancellationToken;
@@ -52,6 +56,7 @@ internal ActivityExecutionContext(
5256
{ "activity_type", info.ActivityType },
5357
});
5458
});
59+
this.temporalClient = temporalClient;
5560
}
5661
#pragma warning restore CA1068
5762

@@ -107,6 +112,18 @@ internal ActivityExecutionContext(
107112
/// </summary>
108113
public MetricMeter MetricMeter => metricMeter.Value;
109114

115+
/// <summary>
116+
/// Gets the Temporal client for use within the activity.
117+
/// </summary>
118+
/// <exception cref="InvalidOperationException">If this is running in a
119+
/// <see cref="Testing.ActivityEnvironment"/> and no client was provided.</exception>
120+
/// <exception cref="InvalidOperationException">If the client the worker was created with is
121+
/// not an <c>ITemporalClient</c>.</exception>
122+
public ITemporalClient TemporalClient => temporalClient ??
123+
throw new InvalidOperationException("No Temporal client available. " +
124+
"This could either be a test environment without a client set, or the worker was " +
125+
"created in an advanced way without an ITemporalClient instance.");
126+
110127
/// <summary>
111128
/// Gets the async local current value.
112129
/// </summary>

src/Temporalio/Testing/ActivityEnvironment.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.Extensions.Logging.Abstractions;
77
using Temporalio.Activities;
88
using Temporalio.Api.Common.V1;
9+
using Temporalio.Client;
910
using Temporalio.Common;
1011
using Temporalio.Converters;
1112

@@ -60,6 +61,12 @@ public record ActivityEnvironment
6061
/// </summary>
6162
public MetricMeter? MetricMeter { get; init; }
6263

64+
/// <summary>
65+
/// Gets or inits the Temporal client accessible from the activity context. If unset, an
66+
/// exception is thrown when the client is accessed.
67+
/// </summary>
68+
public ITemporalClient? TemporalClient { get; init; }
69+
6370
/// <summary>
6471
/// Gets or sets the cancel reason. Callers may prefer <see cref="Cancel" /> instead.
6572
/// </summary>
@@ -134,7 +141,8 @@ public async Task<T> RunAsync<T>(Func<Task<T>> activity)
134141
taskToken: ByteString.Empty,
135142
logger: Logger,
136143
payloadConverter: PayloadConverter,
137-
runtimeMetricMeter: new(() => MetricMeter ?? MetricMeterNoop.Instance))
144+
runtimeMetricMeter: new(() => MetricMeter ?? MetricMeterNoop.Instance),
145+
temporalClient: TemporalClient)
138146
{
139147
Heartbeater = Heartbeater,
140148
CancelReasonRef = CancelReasonRef,

src/Temporalio/Worker/ActivityWorker.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Google.Protobuf.WellKnownTypes;
1111
using Microsoft.Extensions.Logging;
1212
using Temporalio.Activities;
13+
using Temporalio.Client;
1314
using Temporalio.Converters;
1415
using Temporalio.Exceptions;
1516
using Temporalio.Worker.Interceptors;
@@ -191,7 +192,8 @@ private void StartActivity(Bridge.Api.ActivityTask.ActivityTask tsk)
191192
taskToken: tsk.TaskToken,
192193
logger: worker.LoggerFactory.CreateLogger($"Temporalio.Activity:{info.ActivityType}"),
193194
payloadConverter: worker.Client.Options.DataConverter.PayloadConverter,
194-
runtimeMetricMeter: worker.MetricMeter);
195+
runtimeMetricMeter: worker.MetricMeter,
196+
temporalClient: worker.Client as ITemporalClient);
195197

196198
// Start task
197199
using (context.Logger.BeginScope(info.LoggerScope))

src/Temporalio/Worker/WorkflowInstance.cs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,19 @@ public WorkflowUpdateDefinition? DynamicUpdate
284284
/// <inheritdoc />
285285
public WorkflowInfo Info { get; private init; }
286286

287+
/// <inheritdoc />
288+
///
289+
/// This is lazily created and should never be called outside of the scheduler
290+
public object Instance
291+
{
292+
get
293+
{
294+
// We create this lazily because we want the constructor in a workflow context
295+
instance ??= Definition.CreateWorkflowInstance(startArgs!.Value);
296+
return instance;
297+
}
298+
}
299+
287300
/// <inheritdoc />
288301
public bool IsReplaying { get; private set; }
289302

@@ -322,20 +335,6 @@ public WorkflowUpdateDefinition? DynamicUpdate
322335
/// </summary>
323336
internal WorkflowDefinition Definition { get; private init; }
324337

325-
/// <summary>
326-
/// Gets the instance, lazily creating if needed. This should never be called outside this
327-
/// scheduler.
328-
/// </summary>
329-
private object Instance
330-
{
331-
get
332-
{
333-
// We create this lazily because we want the constructor in a workflow context
334-
instance ??= Definition.CreateWorkflowInstance(startArgs!.Value);
335-
return instance;
336-
}
337-
}
338-
339338
/// <inheritdoc/>
340339
public ContinueAsNewException CreateContinueAsNewException(
341340
string workflow, IReadOnlyCollection<object?> args, ContinueAsNewOptions? options) =>

src/Temporalio/Workflows/IWorkflowContext.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ internal interface IWorkflowContext
7373
/// </summary>
7474
WorkflowInfo Info { get; }
7575

76+
/// <summary>
77+
/// Gets value for <see cref="Workflow.Instance" />.
78+
/// </summary>
79+
object Instance { get; }
80+
7681
/// <summary>
7782
/// Gets a value indicating whether <see cref="Workflow.Unsafe.IsReplaying" /> is true.
7883
/// </summary>

src/Temporalio/Workflows/Workflow.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ public static WorkflowUpdateDefinition? DynamicUpdate
134134
/// </summary>
135135
public static WorkflowInfo Info => Context.Info;
136136

137+
/// <summary>
138+
/// Gets the instance of the current workflow class.
139+
/// </summary>
140+
public static object Instance => Context.Instance;
141+
137142
/// <summary>
138143
/// Gets a value indicating whether this code is currently running in a workflow.
139144
/// </summary>

0 commit comments

Comments
 (0)