Merge pull request #7904 from elsa-workflows/codex/fix-7306-tenant-agnostic-registry-population

Avoid repeated tenant-agnostic registry population
This commit is contained in:
Sipke Schoorstra 2026-08-02 03:25:43 +02:00 committed by GitHub
commit ed50e1cc96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 419 additions and 34 deletions

View file

@ -95,10 +95,21 @@ public interface IActivityRegistry : IActivityProvider
/// <summary>
/// Refreshes the activity descriptors in the registry by querying the specified activity provider.
/// </summary>
Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default);
/// <summary>
/// Clears all activity descriptors from the registry.
Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default);
/// <summary>
/// Ensures that descriptors from a tenant-agnostic activity provider have been initialized.
/// </summary>
/// <param name="activityProvider">The activity provider used to retrieve the descriptors.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <remarks>
/// Tenant-sensitive providers are refreshed on every call. The default implementation refreshes every provider to preserve compatibility with custom registry implementations.
/// </remarks>
Task EnsureDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) =>
RefreshDescriptorsAsync(activityProvider, cancellationToken);
/// <summary>
/// Clears all activity descriptors from the registry.
/// </summary>
void Clear();
@ -107,4 +118,4 @@ public interface IActivityRegistry : IActivityProvider
/// </summary>
/// <param name="providerType">The type of the activity provider.</param>
void ClearProvider(Type providerType);
}
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Workflows;
/// <summary>
/// Marks an activity provider whose descriptors do not depend on the current tenant.
/// </summary>
/// <remarks>
/// Descriptors from a tenant-agnostic provider can be initialized once for each
/// <see cref="IActivityRegistry"/> instance.
/// </remarks>
public interface ITenantAgnosticActivityProvider : IActivityProvider;

View file

@ -16,9 +16,13 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
// Per-tenant activity descriptors (workflow-as-activities, tenant-specific providers, etc.)
private readonly ConcurrentDictionary<string, TenantRegistryData> _tenantRegistries = new();
// Tenant-agnostic activity descriptors (built-in activities, manually registered, etc.)
private readonly TenantRegistryData _agnosticRegistry = new();
// Tenant-agnostic activity descriptors (built-in activities, manually registered, etc.)
private readonly TenantRegistryData _agnosticRegistry = new();
// Tracks tenant-agnostic providers initialized for this registry instance.
private readonly ConcurrentDictionary<Type, byte> _initializedProviders = new();
private readonly ConcurrentDictionary<Type, SemaphoreSlim> _providerInitializationLocks = new();
/// <inheritdoc />
public void Add(Type providerType, ActivityDescriptor descriptor)
{
@ -182,7 +186,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
await RefreshDescriptorsAsync(activityProvider, cancellationToken);
}
public async Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default)
public async Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default)
{
var providerType = activityProvider.GetType();
@ -216,9 +220,39 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
// Update the provider's descriptor list in this registry
registry.ProvidedActivityDescriptors[providerType] = providerDescriptors;
}
}
}
}
/// <inheritdoc />
public async Task EnsureDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default)
{
if (activityProvider is not ITenantAgnosticActivityProvider)
{
await RefreshDescriptorsAsync(activityProvider, cancellationToken);
return;
}
var providerType = activityProvider.GetType();
if (_initializedProviders.ContainsKey(providerType))
return;
var initializationLock = _providerInitializationLocks.GetOrAdd(providerType, _ => new(1, 1));
await initializationLock.WaitAsync(cancellationToken);
try
{
if (_initializedProviders.ContainsKey(providerType))
return;
await RefreshDescriptorsAsync(activityProvider, cancellationToken);
_initializedProviders.TryAdd(providerType, 0);
}
finally
{
initializationLock.Release();
}
}
private void Add(ActivityDescriptor? descriptor, TenantRegistryData registry, ICollection<ActivityDescriptor> providerDescriptors)
{
if (descriptor is null)
@ -253,15 +287,17 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
{
_manualActivityDescriptors.Clear();
_tenantRegistries.Clear();
_agnosticRegistry.ActivityDescriptors.Clear();
_agnosticRegistry.LatestActivityDescriptors.Clear();
_agnosticRegistry.ProvidedActivityDescriptors.Clear();
}
_agnosticRegistry.ActivityDescriptors.Clear();
_agnosticRegistry.LatestActivityDescriptors.Clear();
_agnosticRegistry.ProvidedActivityDescriptors.Clear();
_initializedProviders.Clear();
}
/// <inheritdoc />
public void ClearProvider(Type providerType)
{
var currentTenantId = tenantAccessor.TenantId;
public void ClearProvider(Type providerType)
{
_initializedProviders.TryRemove(providerType, out _);
var currentTenantId = tenantAccessor.TenantId;
// Clear from current tenant's registry
if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry)

View file

@ -11,7 +11,7 @@ namespace Elsa.Workflows.Management.Activities.HostMethod;
/// Inputs come from public properties and method parameters.
/// </summary>
[UsedImplicitly]
public class HostMethodActivityProvider(IOptions<HostMethodActivitiesOptions> options, IHostMethodActivityDescriber hostMethodActivityDescriber) : IActivityProvider
public class HostMethodActivityProvider(IOptions<HostMethodActivitiesOptions> options, IHostMethodActivityDescriber hostMethodActivityDescriber) : ITenantAgnosticActivityProvider
{
public async ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{

View file

@ -10,4 +10,14 @@ public interface IActivityRegistryPopulator
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
Task PopulateRegistryAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Ensures that activity descriptors have been populated, initializing tenant-agnostic providers once.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <remarks>
/// The default implementation preserves the existing behavior for custom populators.
/// </remarks>
Task EnsureRegistryPopulatedAsync(CancellationToken cancellationToken = default) =>
PopulateRegistryAsync(cancellationToken);
}

View file

@ -10,7 +10,7 @@ namespace Elsa.Workflows.Management.Providers;
/// Provides activity descriptors based on a list of activity types registered in the <see cref="ManagementOptions"/>.
/// </summary>
[UsedImplicitly]
public class TypedActivityProvider : IActivityProvider
public class TypedActivityProvider : ITenantAgnosticActivityProvider
{
private readonly IActivityDescriber _activityDescriber;
private readonly ManagementOptions _options;
@ -40,4 +40,4 @@ public class TypedActivityProvider : IActivityProvider
yield return descriptor;
}
}
}
}

View file

@ -10,4 +10,11 @@ public class ActivityRegistryPopulator(IEnumerable<IActivityProvider> providers,
{
await registry.RefreshDescriptorsAsync(providers, cancellationToken);
}
}
/// <inheritdoc />
public async Task EnsureRegistryPopulatedAsync(CancellationToken cancellationToken)
{
foreach (var provider in providers)
await registry.EnsureDescriptorsAsync(provider, cancellationToken);
}
}

View file

@ -14,16 +14,16 @@ public class DefaultRegistriesPopulator(
/// <inheritdoc />
public async Task PopulateAsync(CancellationToken cancellationToken = default)
{
// Stage 1: Populate the activity registry.
// Because workflow definitions can be used as activities, we need to make sure that the activity registry is populated before we populate the workflow definition store.
await activityRegistryPopulator.PopulateRegistryAsync(cancellationToken);
// Stage 1: Ensure the activity registry is populated.
// Tenant-agnostic providers are initialized once, while tenant-sensitive providers are refreshed before the workflow definition store is populated.
await activityRegistryPopulator.EnsureRegistryPopulatedAsync(cancellationToken);
// Stage 2: Populate the workflow definition store.
await workflowDefinitionStorePopulator.PopulateStoreAsync(false, cancellationToken);
// Stage 3: Re-populate the activity registry.
// After the workflow definition store has been populated, we need to re-populate the activity registry to make sure that the activity descriptors are up-to-date.
await activityRegistryPopulator.PopulateRegistryAsync(cancellationToken);
// Stage 3: Refresh tenant-sensitive activity providers.
// After the workflow definition store has been populated, refresh tenant-specific descriptors such as workflow-as-activity definitions.
await activityRegistryPopulator.EnsureRegistryPopulatedAsync(cancellationToken);
// Stage 4. Re-update the workflow definition store with the current set of activities.
// Finally, we need to re-populate the workflow definition store to make sure that the workflow definitions are up-to-date.
@ -34,4 +34,4 @@ public class DefaultRegistriesPopulator(
var notification = new WorkflowDefinitionsReloaded(reloadedWorkflowDefinitions);
await notificationSender.SendAsync(notification, cancellationToken);
}
}
}

View file

@ -425,6 +425,112 @@ public class ActivityRegistryTests
AssertDescriptor(result, CurrentTenant, 2);
}
[Fact]
public async Task EnsureDescriptorsAsync_InitializesTenantAgnosticProviderOnlyOnce_WhenCalledRepeatedly()
{
// Arrange
var provider = new CountingTenantAgnosticProvider();
// Act
await _registry.EnsureDescriptorsAsync(provider);
await _registry.EnsureDescriptorsAsync(provider);
// Assert
Assert.Equal(1, provider.CallCount);
}
[Fact]
public async Task EnsureDescriptorsAsync_InitializesTenantAgnosticProviderOnlyOnce_WhenCalledConcurrently()
{
// Arrange
var provider = new BlockingTenantAgnosticProvider();
var initializationTasks = Enumerable.Range(0, 8)
.Select(_ => _registry.EnsureDescriptorsAsync(provider))
.ToArray();
await provider.FirstCallStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
await Task.Yield();
provider.Release();
// Act
await Task.WhenAll(initializationTasks);
// Assert
Assert.Equal(1, provider.CallCount);
}
[Fact]
public async Task EnsureDescriptorsAsync_RetriesTenantAgnosticProvider_AfterFailure()
{
// Arrange
var provider = new CountingTenantAgnosticProvider([new InvalidOperationException("Expected failure")]);
// Act
await Assert.ThrowsAsync<InvalidOperationException>(() => _registry.EnsureDescriptorsAsync(provider));
await _registry.EnsureDescriptorsAsync(provider);
// Assert
Assert.Equal(2, provider.CallCount);
}
[Fact]
public async Task EnsureDescriptorsAsync_RetriesTenantAgnosticProvider_AfterCancellation()
{
// Arrange
var provider = new CountingTenantAgnosticProvider([new OperationCanceledException()]);
// Act
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => _registry.EnsureDescriptorsAsync(provider));
await _registry.EnsureDescriptorsAsync(provider);
// Assert
Assert.Equal(2, provider.CallCount);
}
[Fact]
public async Task EnsureDescriptorsAsync_ReinitializesTenantAgnosticProvider_AfterRegistryIsCleared()
{
// Arrange
var provider = new CountingTenantAgnosticProvider();
await _registry.EnsureDescriptorsAsync(provider);
// Act
_registry.Clear();
await _registry.EnsureDescriptorsAsync(provider);
// Assert
Assert.Equal(2, provider.CallCount);
}
[Fact]
public async Task EnsureDescriptorsAsync_ReinitializesTenantAgnosticProvider_AfterProviderIsCleared()
{
// Arrange
var provider = new CountingTenantAgnosticProvider();
await _registry.EnsureDescriptorsAsync(provider);
// Act
_registry.ClearProvider(provider.GetType());
await _registry.EnsureDescriptorsAsync(provider);
// Assert
Assert.Equal(2, provider.CallCount);
}
[Fact]
public async Task EnsureDescriptorsAsync_RefreshesTenantSensitiveProvider_OnEveryCall()
{
// Arrange
var provider = new CountingTenantSensitiveProvider();
// Act
await _registry.EnsureDescriptorsAsync(provider);
await _registry.EnsureDescriptorsAsync(provider);
// Assert
Assert.Equal(2, provider.CallCount);
}
private ActivityDescriptor CreateDescriptor(string typeName, int version, string? tenantId) =>
new()
@ -464,4 +570,73 @@ public class ActivityRegistryTests
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default) => new(Descriptors);
}
private sealed class CountingTenantAgnosticProvider(IEnumerable<Exception>? failures = null) : ITenantAgnosticActivityProvider
{
private readonly Queue<Exception> _failures = new(failures ?? []);
private int _callCount;
public int CallCount => Volatile.Read(ref _callCount);
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _callCount);
if (_failures.TryDequeue(out var failure))
return ValueTask.FromException<IEnumerable<ActivityDescriptor>>(failure);
return new([new ActivityDescriptor
{
TypeName = nameof(CountingTenantAgnosticProvider),
Version = 1,
TenantId = Tenant.AgnosticTenantId,
Kind = ActivityKind.Action
}]);
}
}
private sealed class BlockingTenantAgnosticProvider : ITenantAgnosticActivityProvider
{
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _callCount;
public TaskCompletionSource FirstCallStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int CallCount => Volatile.Read(ref _callCount);
public async ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _callCount);
FirstCallStarted.TrySetResult();
await _release.Task.WaitAsync(cancellationToken);
return [new ActivityDescriptor
{
TypeName = nameof(BlockingTenantAgnosticProvider),
Version = 1,
TenantId = Tenant.AgnosticTenantId,
Kind = ActivityKind.Action
}];
}
public void Release() => _release.TrySetResult();
}
private sealed class CountingTenantSensitiveProvider : IActivityProvider
{
private int _callCount;
public int CallCount => Volatile.Read(ref _callCount);
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _callCount);
return new([new ActivityDescriptor
{
TypeName = nameof(CountingTenantSensitiveProvider),
Version = 1,
TenantId = CurrentTenant,
Kind = ActivityKind.Action
}]);
}
}
}

View file

@ -0,0 +1,111 @@
using Elsa.Common.Multitenancy;
using Elsa.Workflows.Management.Activities.HostMethod;
using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity;
using Elsa.Workflows.Management.Providers;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Elsa.Workflows.Management.UnitTests.Services;
public class ActivityRegistryPopulatorTests
{
[Fact]
public async Task EnsureRegistryPopulatedAsync_InitializesAgnosticProvidersOnceAcrossScopedPopulators_AndRefreshesTenantSensitiveProvidersPerPass()
{
// Arrange
var agnosticProviderCallCount = new CallCounter();
var tenantSensitiveProviderCallCount = new CallCounter();
var registry = CreateRegistry();
IActivityRegistryPopulator firstScopePopulator = new ActivityRegistryPopulator(
[new CountingTenantAgnosticProvider(agnosticProviderCallCount), new CountingTenantSensitiveProvider(tenantSensitiveProviderCallCount)], registry);
IActivityRegistryPopulator secondScopePopulator = new ActivityRegistryPopulator(
[new CountingTenantAgnosticProvider(agnosticProviderCallCount), new CountingTenantSensitiveProvider(tenantSensitiveProviderCallCount)], registry);
// Act
await firstScopePopulator.EnsureRegistryPopulatedAsync();
await secondScopePopulator.EnsureRegistryPopulatedAsync();
// Assert
Assert.Equal(1, agnosticProviderCallCount.Value);
Assert.Equal(2, tenantSensitiveProviderCallCount.Value);
}
[Fact]
public async Task PopulateRegistryAsync_ForceRefreshesAllProvidersOnEveryPass()
{
// Arrange
var agnosticProviderCallCount = new CallCounter();
var tenantSensitiveProviderCallCount = new CallCounter();
var agnosticProvider = new CountingTenantAgnosticProvider(agnosticProviderCallCount);
var tenantSensitiveProvider = new CountingTenantSensitiveProvider(tenantSensitiveProviderCallCount);
IActivityRegistryPopulator populator = new ActivityRegistryPopulator([agnosticProvider, tenantSensitiveProvider], CreateRegistry());
// Act
await populator.PopulateRegistryAsync();
await populator.PopulateRegistryAsync();
// Assert
Assert.Equal(2, agnosticProviderCallCount.Value);
Assert.Equal(2, tenantSensitiveProviderCallCount.Value);
}
[Fact]
public void BuiltInTenantAgnosticProviders_OptIntoOncePerRegistryPopulation()
{
Assert.True(typeof(ITenantAgnosticActivityProvider).IsAssignableFrom(typeof(TypedActivityProvider)));
Assert.True(typeof(ITenantAgnosticActivityProvider).IsAssignableFrom(typeof(HostMethodActivityProvider)));
Assert.False(typeof(ITenantAgnosticActivityProvider).IsAssignableFrom(typeof(WorkflowDefinitionActivityProvider)));
}
private static ActivityRegistry CreateRegistry()
{
var tenantAccessor = Substitute.For<ITenantAccessor>();
tenantAccessor.TenantId.Returns("tenant-1");
return new ActivityRegistry(
Substitute.For<IActivityDescriber>(),
[],
tenantAccessor,
Substitute.For<ILogger<ActivityRegistry>>());
}
private sealed class CountingTenantAgnosticProvider(CallCounter callCounter) : ITenantAgnosticActivityProvider
{
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
callCounter.Increment();
return new([new ActivityDescriptor
{
TypeName = nameof(CountingTenantAgnosticProvider),
Version = 1,
TenantId = Tenant.AgnosticTenantId,
Kind = ActivityKind.Action
}]);
}
}
private sealed class CountingTenantSensitiveProvider(CallCounter callCounter) : IActivityProvider
{
public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
callCounter.Increment();
return new([new ActivityDescriptor
{
TypeName = nameof(CountingTenantSensitiveProvider),
Version = 1,
TenantId = "tenant-1",
Kind = ActivityKind.Action
}]);
}
}
private sealed class CallCounter
{
private int _value;
public int Value => Volatile.Read(ref _value);
public void Increment() => Interlocked.Increment(ref _value);
}
}

View file

@ -9,18 +9,19 @@ namespace Elsa.Workflows.Runtime.UnitTests.Services;
public class DefaultRegistriesPopulatorTests
{
private readonly IWorkflowDefinitionStorePopulator _workflowDefinitionStorePopulatorMock;
private readonly IActivityRegistryPopulator _activityRegistryPopulatorMock;
private readonly INotificationSender _notificationSenderMock;
private readonly DefaultRegistriesPopulator _populator;
public DefaultRegistriesPopulatorTests()
{
_workflowDefinitionStorePopulatorMock = Substitute.For<IWorkflowDefinitionStorePopulator>();
var activityRegistryPopulatorMock = Substitute.For<IActivityRegistryPopulator>();
_activityRegistryPopulatorMock = Substitute.For<IActivityRegistryPopulator>();
_notificationSenderMock = Substitute.For<INotificationSender>();
_populator = new(
_workflowDefinitionStorePopulatorMock,
activityRegistryPopulatorMock,
_activityRegistryPopulatorMock,
_notificationSenderMock);
}
@ -56,4 +57,28 @@ public class DefaultRegistriesPopulatorTests
Arg.Is<WorkflowDefinitionsReloaded>(n => n.ReloadedWorkflowDefinitions.Count == 2),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task PopulateAsync_EnsuresActivityRegistryBeforeEachWorkflowDefinitionStorePopulation()
{
// Arrange
_workflowDefinitionStorePopulatorMock
.PopulateStoreAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<WorkflowDefinition>());
// Act
await _populator.PopulateAsync();
// Assert
Received.InOrder(() =>
{
_activityRegistryPopulatorMock.EnsureRegistryPopulatedAsync(Arg.Any<CancellationToken>());
_workflowDefinitionStorePopulatorMock.PopulateStoreAsync(false, Arg.Any<CancellationToken>());
_activityRegistryPopulatorMock.EnsureRegistryPopulatedAsync(Arg.Any<CancellationToken>());
_workflowDefinitionStorePopulatorMock.PopulateStoreAsync(true, Arg.Any<CancellationToken>());
_notificationSenderMock.SendAsync(Arg.Any<WorkflowDefinitionsReloaded>(), Arg.Any<CancellationToken>());
});
await _activityRegistryPopulatorMock.Received(2).EnsureRegistryPopulatedAsync(Arg.Any<CancellationToken>());
}
}