From 9554385498793017d7eee813df29c7690d4391fa Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 27 May 2026 09:09:17 +0200 Subject: [PATCH 1/7] Optimize activity registry lookup Add a latest-version descriptor index per tenant registry so ActivityRegistry.Find(type) can avoid scanning every registered descriptor. Keep the index in sync when descriptors are added, removed, refreshed, or cleared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/TenantRegistryData.cs | 5 + .../Services/ActivityRegistry.cs | 68 +++++--- .../Services/ActivityRegistryTests.cs | 148 ++++++++++++++++-- 3 files changed, 185 insertions(+), 36 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs b/src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs index cc42574e1..0ce371b41 100644 --- a/src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs +++ b/src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs @@ -30,6 +30,11 @@ public class TenantRegistryData /// public ConcurrentDictionary<(string Type, int Version), ActivityDescriptor> ActivityDescriptors { get; } = new(); + /// + /// Cached latest-version activity descriptors by type for fast lookups. + /// + internal ConcurrentDictionary LatestActivityDescriptors { get; } = new(); + /// /// Secondary index of activity descriptors grouped by their provider type. /// diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index 043fc9c33..b0f1184b0 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -24,7 +24,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< { var registry = GetOrCreateRegistry(descriptor.TenantId); var providerDescriptors = GetOrCreateProviderDescriptors(registry, providerType); - Add(descriptor, registry.ActivityDescriptors, providerDescriptors); + Add(descriptor, registry, providerDescriptors); } /// @@ -34,7 +34,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< if (registry.ProvidedActivityDescriptors.TryGetValue(providerType, out var providerDescriptors)) { providerDescriptors.Remove(descriptor); - registry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out _); + RemoveDescriptor(registry, descriptor); } } @@ -82,18 +82,14 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Get highest version from current tenant's registry if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry)) { - var tenantDescriptor = tenantRegistry.ActivityDescriptors.Values - .Where(x => x.TypeName == type) - .MaxBy(x => x.Version); - - if (tenantDescriptor != null) + if (tenantRegistry.LatestActivityDescriptors.TryGetValue(type, out var tenantDescriptor)) return tenantDescriptor; } // Fall back to agnostic registry only if no tenant-specific descriptor exists - return _agnosticRegistry.ActivityDescriptors.Values - .Where(x => x.TypeName == type) - .MaxBy(x => x.Version); + return _agnosticRegistry.LatestActivityDescriptors.TryGetValue(type, out var agnosticDescriptor) + ? agnosticDescriptor + : null; } /// @@ -151,7 +147,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< { var registry = GetOrCreateRegistry(descriptor.TenantId); var providerDescriptors = GetOrCreateProviderDescriptors(registry, GetType()); - Add(descriptor, registry.ActivityDescriptors, providerDescriptors); + Add(descriptor, registry, providerDescriptors); } /// @@ -166,7 +162,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< var activityDescriptor = await activityDescriber.DescribeActivityAsync(activityType, cancellationToken); var registry = GetOrCreateRegistry(activityDescriptor.TenantId); - Add(activityDescriptor, registry.ActivityDescriptors, _manualActivityDescriptors); + Add(activityDescriptor, registry, _manualActivityDescriptors); _manualActivityDescriptors.Add(activityDescriptor); } @@ -208,7 +204,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< { foreach (var oldDescriptor in oldDescriptors.ToList()) { - registry.ActivityDescriptors.TryRemove((oldDescriptor.TypeName, oldDescriptor.Version), out _); + RemoveDescriptor(registry, oldDescriptor); } } @@ -216,7 +212,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< var providerDescriptors = new List(); foreach (var descriptor in group) { - Add(descriptor, registry.ActivityDescriptors, providerDescriptors); + Add(descriptor, registry, providerDescriptors); } // Update the provider's descriptor list in this registry @@ -224,7 +220,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< } } - private void Add(ActivityDescriptor? descriptor, ConcurrentDictionary<(string Type, int Version), ActivityDescriptor> activityDescriptors, ICollection providerDescriptors) + private void Add(ActivityDescriptor? descriptor, TenantRegistryData registry, ICollection providerDescriptors) { if (descriptor is null) { @@ -235,8 +231,11 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< foreach (var modifier in modifiers) modifier.Modify(descriptor); + var activityDescriptors = registry.ActivityDescriptors; + var descriptorKey = (descriptor.TypeName, descriptor.Version); + // If the descriptor already exists, replace it. But log a warning. - if (activityDescriptors.TryGetValue((descriptor.TypeName, descriptor.Version), out var existingDescriptor)) + if (activityDescriptors.TryGetValue(descriptorKey, out var existingDescriptor)) { // Remove the existing descriptor from the providerDescriptors collection. providerDescriptors.Remove(existingDescriptor); @@ -245,7 +244,8 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< logger.LogWarning("Activity descriptor {ActivityType} v{ActivityVersion} was already registered for tenant {TenantId}. Replacing with new descriptor", descriptor.TypeName, descriptor.Version, descriptor.TenantId); } - activityDescriptors[(descriptor.TypeName, descriptor.Version)] = descriptor; + activityDescriptors[descriptorKey] = descriptor; + UpdateLatestDescriptor(registry, descriptor); providerDescriptors.Add(descriptor); } @@ -254,6 +254,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< { _tenantRegistries.Clear(); _agnosticRegistry.ActivityDescriptors.Clear(); + _agnosticRegistry.LatestActivityDescriptors.Clear(); _agnosticRegistry.ProvidedActivityDescriptors.Clear(); } @@ -267,7 +268,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< && tenantRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var descriptors)) { foreach (var descriptor in descriptors.ToList()) - tenantRegistry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out _); + RemoveDescriptor(tenantRegistry, descriptor); tenantRegistry.ProvidedActivityDescriptors.TryRemove(providerType, out _); } @@ -276,7 +277,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< if (_agnosticRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var agnosticDescriptors)) { foreach (var descriptor in agnosticDescriptors.ToList()) - _agnosticRegistry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out _); + RemoveDescriptor(_agnosticRegistry, descriptor); _agnosticRegistry.ProvidedActivityDescriptors.TryRemove(providerType, out _); } @@ -305,6 +306,35 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< return registry.ProvidedActivityDescriptors.GetOrAdd(providerType, _ => new List()); } + private static void UpdateLatestDescriptor(TenantRegistryData registry, ActivityDescriptor descriptor) + { + registry.LatestActivityDescriptors.AddOrUpdate( + descriptor.TypeName, + descriptor, + (_, latestDescriptor) => descriptor.Version >= latestDescriptor.Version ? descriptor : latestDescriptor); + } + + private static void RemoveDescriptor(TenantRegistryData registry, ActivityDescriptor descriptor) + { + if (!registry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out var removedDescriptor)) + return; + + if (registry.LatestActivityDescriptors.TryGetValue(removedDescriptor.TypeName, out var latestDescriptor) && latestDescriptor.Version == removedDescriptor.Version) + RecomputeLatestDescriptor(registry, removedDescriptor.TypeName); + } + + private static void RecomputeLatestDescriptor(TenantRegistryData registry, string typeName) + { + var latestDescriptor = registry.ActivityDescriptors.Values + .Where(x => x.TypeName == typeName) + .MaxBy(x => x.Version); + + if (latestDescriptor == null) + registry.LatestActivityDescriptors.TryRemove(typeName, out _); + else + registry.LatestActivityDescriptors[typeName] = latestDescriptor; + } + /// /// Normalizes tenant ID for grouping purposes. /// Converts null to "*" so that both null and "*" descriptors are grouped together, diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs index 0ae94391f..c32f0fb54 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs @@ -22,7 +22,7 @@ public class ActivityRegistryTests var activityDescriber = Substitute.For(); _logger = Substitute.For>(); _registry = new(activityDescriber, [], tenantAccessor, _logger); - + // Set default tenant for all tests tenantAccessor.TenantId.Returns(CurrentTenant); } @@ -114,6 +114,88 @@ public class ActivityRegistryTests Assert.Null(result); } + [Fact] + public void Find_ReturnsNextLatestVersion_WhenLatestDescriptorRemoved() + { + // Arrange + var v1 = CreateDescriptor(TestActivityType, 1, CurrentTenant); + var v2 = CreateDescriptor(TestActivityType, 2, CurrentTenant); + var v3 = CreateDescriptor(TestActivityType, 3, CurrentTenant); + RegisterDescriptors(v1, v2, v3); + + // Act + _registry.Remove(typeof(ActivityRegistry), v3); + var result = _registry.Find(TestActivityType); + + // Assert + AssertDescriptor(result, CurrentTenant, 2); + } + + [Fact] + public void Find_KeepsLatestVersion_WhenNonLatestDescriptorRemoved() + { + // Arrange + var v1 = CreateDescriptor(TestActivityType, 1, CurrentTenant); + var v2 = CreateDescriptor(TestActivityType, 2, CurrentTenant); + var v3 = CreateDescriptor(TestActivityType, 3, CurrentTenant); + RegisterDescriptors(v1, v2, v3); + + // Act + _registry.Remove(typeof(ActivityRegistry), v1); + var result = _registry.Find(TestActivityType); + + // Assert + AssertDescriptor(result, CurrentTenant, 3); + } + + [Fact] + public void Find_ReturnsNull_WhenProviderWithLatestDescriptorClearedAndNoDescriptorsRemain() + { + // Arrange + var descriptor = CreateDescriptor(TestActivityType, 1, CurrentTenant); + _registry.Add(typeof(Provider1), descriptor); + + // Act + _registry.ClearProvider(typeof(Provider1)); + var result = _registry.Find(TestActivityType); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Find_RecomputesLatestVersion_WhenProviderWithLatestDescriptorCleared() + { + // Arrange + var provider1V1 = CreateDescriptor(TestActivityType, 1, CurrentTenant); + var provider1V3 = CreateDescriptor(TestActivityType, 3, CurrentTenant); + var provider2V2 = CreateDescriptor(TestActivityType, 2, CurrentTenant); + _registry.Add(typeof(Provider1), provider1V1); + _registry.Add(typeof(Provider1), provider1V3); + _registry.Add(typeof(Provider2), provider2V2); + + // Act + _registry.ClearProvider(typeof(Provider1)); + var result = _registry.Find(TestActivityType); + + // Assert + AssertDescriptor(result, CurrentTenant, 2); + } + + [Fact] + public void Find_ReturnsNull_WhenRegistryCleared() + { + // Arrange + RegisterDescriptors(CreateDescriptor(TestActivityType, 2, CurrentTenant)); + + // Act + _registry.Clear(); + var result = _registry.Find(TestActivityType); + + // Assert + Assert.Null(result); + } + [Fact] public void Find_IgnoresOtherTenantDescriptors_OnlyReturnsCurrentTenantOrAgnostic() { @@ -147,7 +229,7 @@ public class ActivityRegistryTests Description = "Test Activity 1", IsBrowsable = true }; - + var descriptor2 = new ActivityDescriptor { TypeName = "TestActivity2", @@ -157,18 +239,18 @@ public class ActivityRegistryTests Description = "Test Activity 2", IsBrowsable = true }; - + mockProvider.GetDescriptorsAsync(Arg.Any()) .Returns(new ValueTask>([descriptor1, descriptor2])); var providers = new[] { mockProvider }; - + // Act - First refresh await _registry.RefreshDescriptorsAsync(providers); - + // Act - Second refresh (simulates the intentional repopulation in DefaultRegistriesPopulator) await _registry.RefreshDescriptorsAsync(providers); - + // Assert - Verify no warning logs were made _logger.DidNotReceive().Log( LogLevel.Warning, @@ -176,14 +258,14 @@ public class ActivityRegistryTests Arg.Is(v => v.ToString()!.Contains("was already registered")), Arg.Any(), Arg.Any>()); - + // Verify descriptors are still registered var allDescriptors = _registry.ListAll().ToList(); Assert.Equal(2, allDescriptors.Count); Assert.Contains(allDescriptors, d => d.TypeName == "TestActivity1"); Assert.Contains(allDescriptors, d => d.TypeName == "TestActivity2"); } - + [Fact] public async Task RefreshDescriptorsAsync_PreservesManualDescriptors() { @@ -198,9 +280,9 @@ public class ActivityRegistryTests Description = "Manually registered activity", IsBrowsable = true }; - + _registry.Register(manualDescriptor); - + // Create a provider descriptor var mockProvider = Substitute.For(); var providerDescriptor = new ActivityDescriptor @@ -212,21 +294,21 @@ public class ActivityRegistryTests Description = "Provider activity", IsBrowsable = true }; - + mockProvider.GetDescriptorsAsync(Arg.Any()) .Returns(new ValueTask>([providerDescriptor])); var providers = new[] { mockProvider }; - + // Act - Refresh with provider await _registry.RefreshDescriptorsAsync(providers); - + // Assert - Both manual and provider descriptors should be present var allDescriptors = _registry.ListAll().ToList(); Assert.Equal(2, allDescriptors.Count); Assert.Contains(allDescriptors, d => d.TypeName == "ManualActivity"); Assert.Contains(allDescriptors, d => d.TypeName == "ProviderActivity"); - + // Verify no warnings about manual descriptor being replaced _logger.DidNotReceive().Log( LogLevel.Warning, @@ -235,7 +317,7 @@ public class ActivityRegistryTests Arg.Any(), Arg.Any>()); } - + [Fact] public async Task RefreshDescriptorsAsync_LogsWarning_WhenDifferentProvidersRegisterSameActivity() { @@ -277,7 +359,32 @@ public class ActivityRegistryTests Arg.Any>()); } - + [Fact] + public async Task RefreshDescriptorsAsync_RecomputesLatestDescriptor_WhenProviderDropsLatestVersion() + { + // Arrange + var provider = new MutableProvider( + [ + CreateDescriptor(TestActivityType, 1, CurrentTenant), + CreateDescriptor(TestActivityType, 3, CurrentTenant) + ]); + + await _registry.RefreshDescriptorsAsync(provider); + + provider.Descriptors = + [ + CreateDescriptor(TestActivityType, 1, CurrentTenant) + ]; + + // Act + await _registry.RefreshDescriptorsAsync(provider); + var result = _registry.Find(TestActivityType); + + // Assert + AssertDescriptor(result, CurrentTenant, 1); + } + + private ActivityDescriptor CreateDescriptor(string typeName, int version, string? tenantId) => new() { @@ -308,5 +415,12 @@ public class ActivityRegistryTests { public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) => new(descriptors); } - + + private sealed class MutableProvider(IEnumerable descriptors) : IActivityProvider + { + public IEnumerable Descriptors { get; set; } = descriptors; + + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) => new(Descriptors); + } + } \ No newline at end of file From a98f31141d4a1eb8883f20fe27189c36bf31dc5f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 27 May 2026 09:10:40 +0200 Subject: [PATCH 2/7] Handle provider descriptor removal during refresh Remove descriptors from registries that a refreshed provider no longer contributes to keep descriptor indexes consistent when a provider drops a tenant group entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/ActivityRegistry.cs | 29 +++++++++++++++++-- .../Services/ActivityRegistryTests.cs | 21 ++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index b0f1184b0..ff90b7775 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -193,11 +193,13 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Group descriptors by normalized tenant ID // Normalize null to "*" so both map to the same agnostic group, avoiding redundant processing var descriptorsByTenant = descriptors.GroupBy(d => NormalizeTenantIdForGrouping(d.TenantId)); + var refreshedRegistries = new HashSet(); foreach (var group in descriptorsByTenant) { var tenantId = group.Key; var registry = GetOrCreateRegistry(tenantId); + refreshedRegistries.Add(registry); // Remove old descriptors for this provider from this tenant's registry if (registry.ProvidedActivityDescriptors.TryGetValue(providerType, out var oldDescriptors)) @@ -218,6 +220,17 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Update the provider's descriptor list in this registry registry.ProvidedActivityDescriptors[providerType] = providerDescriptors; } + + foreach (var registry in GetRegistriesWithProvider(providerType).Where(x => !refreshedRegistries.Contains(x))) + { + if (!registry.ProvidedActivityDescriptors.TryRemove(providerType, out var oldDescriptors)) + continue; + + foreach (var oldDescriptor in oldDescriptors.ToList()) + { + RemoveDescriptor(registry, oldDescriptor); + } + } } private void Add(ActivityDescriptor? descriptor, TenantRegistryData registry, ICollection providerDescriptors) @@ -267,7 +280,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry) && tenantRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var descriptors)) { - foreach (var descriptor in descriptors.ToList()) + foreach (var descriptor in descriptors.ToList()) RemoveDescriptor(tenantRegistry, descriptor); tenantRegistry.ProvidedActivityDescriptors.TryRemove(providerType, out _); @@ -276,7 +289,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Clear from agnostic registry if (_agnosticRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var agnosticDescriptors)) { - foreach (var descriptor in agnosticDescriptors.ToList()) + foreach (var descriptor in agnosticDescriptors.ToList()) RemoveDescriptor(_agnosticRegistry, descriptor); _agnosticRegistry.ProvidedActivityDescriptors.TryRemove(providerType, out _); @@ -306,6 +319,18 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< return registry.ProvidedActivityDescriptors.GetOrAdd(providerType, _ => new List()); } + private IEnumerable GetRegistriesWithProvider(Type providerType) + { + if (_agnosticRegistry.ProvidedActivityDescriptors.ContainsKey(providerType)) + yield return _agnosticRegistry; + + foreach (var registry in _tenantRegistries.Values) + { + if (registry.ProvidedActivityDescriptors.ContainsKey(providerType)) + yield return registry; + } + } + private static void UpdateLatestDescriptor(TenantRegistryData registry, ActivityDescriptor descriptor) { registry.LatestActivityDescriptors.AddOrUpdate( diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs index c32f0fb54..53abb9e50 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs @@ -384,6 +384,27 @@ public class ActivityRegistryTests AssertDescriptor(result, CurrentTenant, 1); } + [Fact] + public async Task RefreshDescriptorsAsync_RemovesLatestDescriptor_WhenProviderDropsTenantGroup() + { + // Arrange + var provider = new MutableProvider( + [ + CreateDescriptor(TestActivityType, 2, CurrentTenant) + ]); + + await _registry.RefreshDescriptorsAsync(provider); + + provider.Descriptors = []; + + // Act + await _registry.RefreshDescriptorsAsync(provider); + var result = _registry.Find(TestActivityType); + + // Assert + Assert.Null(result); + } + private ActivityDescriptor CreateDescriptor(string typeName, int version, string? tenantId) => new() From 6d1b7e32113b10ac7a35c650bd0314f0518cbccd Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 31 May 2026 12:35:25 +0200 Subject: [PATCH 3/7] Address activity registry review feedback --- .../Services/ActivityRegistry.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index ff90b7775..9e52e5721 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -324,10 +324,9 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< if (_agnosticRegistry.ProvidedActivityDescriptors.ContainsKey(providerType)) yield return _agnosticRegistry; - foreach (var registry in _tenantRegistries.Values) + foreach (var registry in _tenantRegistries.Values.Where(x => x.ProvidedActivityDescriptors.ContainsKey(providerType))) { - if (registry.ProvidedActivityDescriptors.ContainsKey(providerType)) - yield return registry; + yield return registry; } } @@ -350,9 +349,15 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< private static void RecomputeLatestDescriptor(TenantRegistryData registry, string typeName) { - var latestDescriptor = registry.ActivityDescriptors.Values - .Where(x => x.TypeName == typeName) - .MaxBy(x => x.Version); + ActivityDescriptor? latestDescriptor = null; + foreach (var descriptor in registry.ActivityDescriptors.Values) + { + if (descriptor.TypeName != typeName) + continue; + + if (latestDescriptor == null || descriptor.Version > latestDescriptor.Version) + latestDescriptor = descriptor; + } if (latestDescriptor == null) registry.LatestActivityDescriptors.TryRemove(typeName, out _); @@ -370,4 +375,4 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Normalize null to "*" so both map to the same group return tenantId ?? Tenant.AgnosticTenantId; } -} \ No newline at end of file +} From da4498398ea965e06f4959fec888c4c47cb6f260 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 31 May 2026 12:45:03 +0200 Subject: [PATCH 4/7] Clear manual activity descriptors --- .../Services/ActivityRegistry.cs | 1 + .../Services/ActivityRegistryTests.cs | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index 9e52e5721..7d732833f 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -265,6 +265,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< /// public void Clear() { + _manualActivityDescriptors.Clear(); _tenantRegistries.Clear(); _agnosticRegistry.ActivityDescriptors.Clear(); _agnosticRegistry.LatestActivityDescriptors.Clear(); diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs index 53abb9e50..11f2039f1 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs @@ -13,15 +13,16 @@ public class ActivityRegistryTests private const string TestActivityType = "TestActivity"; private const string CurrentTenant = "tenant1"; + private readonly IActivityDescriber _activityDescriber; private readonly ILogger _logger; private readonly ActivityRegistry _registry; public ActivityRegistryTests() { var tenantAccessor = Substitute.For(); - var activityDescriber = Substitute.For(); + _activityDescriber = Substitute.For(); _logger = Substitute.For>(); - _registry = new(activityDescriber, [], tenantAccessor, _logger); + _registry = new(_activityDescriber, [], tenantAccessor, _logger); // Set default tenant for all tests tenantAccessor.TenantId.Returns(CurrentTenant); @@ -196,6 +197,25 @@ public class ActivityRegistryTests Assert.Null(result); } + [Fact] + public async Task GetDescriptorsAsync_ReturnsEmpty_WhenRegistryCleared() + { + // Arrange + _activityDescriber.DescribeActivityAsync(typeof(ActivityRegistryTests), Arg.Any()) + .Returns(Task.FromResult(CreateDescriptor(TestActivityType, 1, CurrentTenant))); + + await _registry.RegisterAsync(typeof(ActivityRegistryTests), CancellationToken.None); + + // Act + _registry.Clear(); + var descriptors = await _registry.GetDescriptorsAsync(); + + // Assert + Assert.Empty(descriptors); + Assert.Empty(_registry.ListAll()); + Assert.Null(_registry.Find(TestActivityType)); + } + [Fact] public void Find_IgnoresOtherTenantDescriptors_OnlyReturnsCurrentTenantOrAgnostic() { @@ -444,4 +464,4 @@ public class ActivityRegistryTests public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) => new(Descriptors); } -} \ No newline at end of file +} From dbfe247b6a406e769b0f2ac92668003acd899e0e Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 31 May 2026 12:50:36 +0200 Subject: [PATCH 5/7] Integrate ConsoleLogStreaming.Core and ConsoleLogStreaming.SignalR packages; refine activity registry lookup logic. --- Directory.Packages.props | 2 ++ .../Elsa.Diagnostics.ConsoleLogs.csproj | 5 ++--- src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a20a362ba..7ff006312 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -99,6 +99,8 @@ + + diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj index d1b95cc5b..7bfb57e57 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj @@ -9,12 +9,11 @@ + + - - - diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index 7d732833f..47af4319c 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -325,9 +325,10 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< if (_agnosticRegistry.ProvidedActivityDescriptors.ContainsKey(providerType)) yield return _agnosticRegistry; - foreach (var registry in _tenantRegistries.Values.Where(x => x.ProvidedActivityDescriptors.ContainsKey(providerType))) + foreach (var registry in _tenantRegistries.Values) { - yield return registry; + if (registry.ProvidedActivityDescriptors.ContainsKey(providerType)) + yield return registry; } } From 26017e3b73e2279c113231c0371986663ba42d9e Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 31 May 2026 12:56:08 +0200 Subject: [PATCH 6/7] Reduce activity registry refresh allocations --- .../Elsa.Workflows.Core/Services/ActivityRegistry.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index 47af4319c..2f2e0f43b 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -163,7 +163,6 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< var registry = GetOrCreateRegistry(activityDescriptor.TenantId); Add(activityDescriptor, registry, _manualActivityDescriptors); - _manualActivityDescriptors.Add(activityDescriptor); } /// @@ -221,8 +220,11 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< registry.ProvidedActivityDescriptors[providerType] = providerDescriptors; } - foreach (var registry in GetRegistriesWithProvider(providerType).Where(x => !refreshedRegistries.Contains(x))) + foreach (var registry in GetRegistriesWithProvider(providerType)) { + if (refreshedRegistries.Contains(registry)) + continue; + if (!registry.ProvidedActivityDescriptors.TryRemove(providerType, out var oldDescriptors)) continue; From e34132f0e2f22985c10fce00f5f4420a970430b7 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 31 May 2026 13:32:56 +0200 Subject: [PATCH 7/7] Preserve activity descriptors on empty refresh --- .../Services/ActivityRegistry.cs | 28 ------------------- .../Services/ActivityRegistryTests.cs | 4 +-- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index 2f2e0f43b..d1b334b51 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -192,13 +192,11 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Group descriptors by normalized tenant ID // Normalize null to "*" so both map to the same agnostic group, avoiding redundant processing var descriptorsByTenant = descriptors.GroupBy(d => NormalizeTenantIdForGrouping(d.TenantId)); - var refreshedRegistries = new HashSet(); foreach (var group in descriptorsByTenant) { var tenantId = group.Key; var registry = GetOrCreateRegistry(tenantId); - refreshedRegistries.Add(registry); // Remove old descriptors for this provider from this tenant's registry if (registry.ProvidedActivityDescriptors.TryGetValue(providerType, out var oldDescriptors)) @@ -219,20 +217,6 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Update the provider's descriptor list in this registry registry.ProvidedActivityDescriptors[providerType] = providerDescriptors; } - - foreach (var registry in GetRegistriesWithProvider(providerType)) - { - if (refreshedRegistries.Contains(registry)) - continue; - - if (!registry.ProvidedActivityDescriptors.TryRemove(providerType, out var oldDescriptors)) - continue; - - foreach (var oldDescriptor in oldDescriptors.ToList()) - { - RemoveDescriptor(registry, oldDescriptor); - } - } } private void Add(ActivityDescriptor? descriptor, TenantRegistryData registry, ICollection providerDescriptors) @@ -322,18 +306,6 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< return registry.ProvidedActivityDescriptors.GetOrAdd(providerType, _ => new List()); } - private IEnumerable GetRegistriesWithProvider(Type providerType) - { - if (_agnosticRegistry.ProvidedActivityDescriptors.ContainsKey(providerType)) - yield return _agnosticRegistry; - - foreach (var registry in _tenantRegistries.Values) - { - if (registry.ProvidedActivityDescriptors.ContainsKey(providerType)) - yield return registry; - } - } - private static void UpdateLatestDescriptor(TenantRegistryData registry, ActivityDescriptor descriptor) { registry.LatestActivityDescriptors.AddOrUpdate( diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs index 11f2039f1..916971d1b 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs @@ -405,7 +405,7 @@ public class ActivityRegistryTests } [Fact] - public async Task RefreshDescriptorsAsync_RemovesLatestDescriptor_WhenProviderDropsTenantGroup() + public async Task RefreshDescriptorsAsync_PreservesExistingDescriptors_WhenProviderReturnsNoTenantGroups() { // Arrange var provider = new MutableProvider( @@ -422,7 +422,7 @@ public class ActivityRegistryTests var result = _registry.Find(TestActivityType); // Assert - Assert.Null(result); + AssertDescriptor(result, CurrentTenant, 2); }