Implement null TenantId for tenant-agnostic entities (#7226)

* Add ADR for Null Tenant ID, implement tenant-agnostic logic

Introduce ADR-0009 to document the use of `null` for tenant-agnostic entities, enhancing multitenancy handling. Update multitenancy features across the codebase, including EF Core query filters and ActivityRegistry, to handle null as a tenant ID, ensuring tenant-agnostic entities are accessible across all tenants.

* Add multitenancy support in `ActivityTestFixture` by registering `ITenantAccessor`.

* Update src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unused `using Elsa.Common.Multitenancy;` from WorkflowDefinitionActivityDescriptorFactory (#7230)

* Initial plan

* Remove unused using Elsa.Common.Multitenancy statement

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Optimize ActivityRegistry.Find to prefer tenant-specific descriptors without performance regression (#7227)

* Initial plan

* Optimize Find(string type) to prefer tenant-specific descriptors with single-pass iteration

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Apply review feedback: combine if statements and add comprehensive unit tests

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Refactor tests for DRYness using theories and helper methods

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Clean up extra whitespace in test file

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Fix default tenant data visibility leak by removing NullIfEmpty conversion (#7229)

* Initial plan

* Remove NullIfEmpty conversion to align with ADR-0008 and ADR-0009

- Updated ElsaDbContextBase to use empty string for default tenant
- Updated ApplyTenantId to stop converting empty string to null
- Updated TenantAwareDbContextFactory to preserve empty string for default tenant
- Updated Store.cs to preserve empty string for default tenant
- This ensures: null = tenant-agnostic (visible to all), "" = default tenant

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Add database migration to convert null TenantId to empty string for SqlServer

- Added Management migration to convert null to "" for WorkflowDefinitions and WorkflowInstances
- Added Runtime migration to convert null to "" for all runtime entities
- This ensures existing default tenant data is properly migrated per ADR-0008
- Note: Similar migrations needed for PostgreSql, MySql, Sqlite, and Oracle providers

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Clarify tenant handling logic in `ElsaDbContextBase` with new ADR references.

* Add tenant ID convention analysis documents and quick reference guide

* Implement tenant-agnostic functionality across modules

- Introduce `AgnosticTenantId` constant to manage tenant-agnostic entities.
- Modify entity handling logic to respect tenant-agnostic designations.
- Adjust workflow processing to include tenant-agnostic workflows.
- Update caching and activity descriptor logic to accommodate the `AgnosticTenantId`.

* Refactor tenant management and registry logic in `ActivityRegistry` for improved clarity and separation of tenant-specific and tenant-agnostic activity descriptors. Remove `TestTenantResolver` and update workflow definition handling for tenant support.

* Refactor `ActivityRegistry`: prioritize tenant-specific descriptors over tenant-agnostic and simplify descriptor retrieval logic.

* Improve async handling in `CommandHandlerInvokerMiddleware` to await tasks without blocking

* Update ADR to use asterisk as sentinel value for tenant-agnostic entities

Replace the previous convention of using `null` for tenant-agnostic entities with an asterisk (`"*"`) for improved clarity and system architecture. Updated ADR documentation, TOC, and dependency graph accordingly.

* Remove migration `ConvertNullTenantIdToEmptyString` and its associated designer file to clean up the codebase.

* Refactor `ActivityRegistry`: streamline activity descriptor removal logic and simplify tenant ID checks.

* Update Elsa.sln

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Simplify `RefreshDescriptorsAsync` by removing unnecessary local variable `currentTenantId`.

* Remove unused `currentTenantId` variable from `ActivityRegistry`.

* Add detailed semantic flow and key points to ADR 0009

Document the tenant ID flow from entity creation to query, emphasizing normalization and tenant-agnostic workflows. Update semantic flow diagrams and provide testing considerations for preserving `"*"` values in multi-tenant scenarios.

* Remove outdated Tenant ID Analysis and associated documents

* Add security-by-default design for tenant-agnostic entities in ADR

Enhance Architecture Decision Record to detail explicit requirements for tenant-agnostic database entities, highlighting differences between in-memory activity descriptors and persistent entities. Emphasize importance of setting `TenantId = "*"` to prevent accidental data leakage.

* Normalize tenant ID grouping in `ActivityRegistry` to unify null and agnostic IDs, reducing redundant processing.

* Refactor `SignalManager`: improve timeout handling and streamline signal task cancellation.

* Update src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor tests to use `Tenant.AgnosticTenantId` instead of `null` for tenant-agnostic descriptors.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Enhance logging in recurring tasks: add error handling and logger support to prevent crashes in scheduled timers.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2026-02-02 10:59:01 +01:00 committed by GitHub
parent afe33baf01
commit 7bc9035f5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1123 additions and 145 deletions

View file

@ -22,6 +22,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{7D
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
ProjectSection(SolutionItems) = preProject
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}"
EndProject
@ -203,6 +205,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
doc\adr\0006-tenant-deleted-event.md = doc\adr\0006-tenant-deleted-event.md
doc\adr\0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md
doc\adr\0008-empty-string-as-default-tenant-id.md = doc\adr\0008-empty-string-as-default-tenant-id.md
doc\adr\0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md = doc\adr\0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"

View file

@ -0,0 +1,224 @@
# 9. Asterisk Sentinel Value for Tenant-Agnostic Entities
Date: 2026-01-31
## Status
Accepted
## Context
The multitenancy system in Elsa supports both tenant-specific and tenant-agnostic entities. The convention established in ADR-0008 uses an empty string (`""`) as the tenant ID for the default tenant. However, we also need a way to represent entities that are **tenant-agnostic** - entities that should be visible and accessible across all tenants.
Previously, the system did not properly distinguish between:
1. **Default tenant entities** (should only be visible to the default tenant with `TenantId = ""`)
2. **Tenant-agnostic entities** (should be visible to all tenants regardless of their tenant context)
This caused issues where:
- Activity descriptors for built-in activities were being wiped out when different tenants were activated
- The `ActivityRegistry` used a single global dictionary that was replaced during tenant activation via `Interlocked.Exchange`, causing race conditions
- EF Core query filters only matched records with an exact tenant ID match, excluding tenant-agnostic records
- Workflows with no explicit tenant ID could not be found when a specific tenant context was active
### Why Use a Sentinel Value Instead of Null?
We considered using `null` as the marker for tenant-agnostic entities, but chose an explicit sentinel value (`"*"`) instead for several reasons:
1. **Explicit Intent**: A sentinel value makes it crystal clear in code and database queries that an entity is intentionally tenant-agnostic, not accidentally missing a tenant assignment
2. **Simpler Composite Keys**: Avoids nullable handling complexity in composite keys like `(string TenantId, string Type, int Version)` which would become `(string? TenantId, ...)`
3. **Clearer SQL Queries**: Database queries with `TenantId = '*'` are more explicit than `TenantId IS NULL`
4. **Better Logging**: Seeing `"*"` in logs immediately signals tenant-agnostic behavior
5. **Architecture Alignment**: Works seamlessly with the three-dictionary ActivityRegistry architecture where agnostic entities have their own dedicated registry
## Decision
We will use the asterisk character (`"*"`) as a sentinel value to represent **tenant-agnostic entities** - entities that should be accessible across all tenants. This decision includes:
### 1. Convention
- `"*"` (represented by constant `Tenant.AgnosticTenantId`) = tenant-agnostic (visible to all tenants)
- `""` (represented by constant `Tenant.DefaultTenantId`) = default tenant (visible only to default tenant)
- Any other non-null string = specific tenant (visible only to that tenant)
- `null` = not yet assigned (will be normalized to either agnostic or current tenant by handlers)
### 2. Activity Registry Architecture
Implement a **three-dictionary architecture** in `ActivityRegistry` to properly isolate tenant-specific and tenant-agnostic descriptors:
- **`_tenantRegistries`**: `ConcurrentDictionary<string, TenantRegistryData>` - Per-tenant activity descriptors (e.g., workflow-as-activities)
- **`_agnosticRegistry`**: `TenantRegistryData` - Shared tenant-agnostic descriptors (e.g., built-in activities)
- **`_manualActivityDescriptors`**: `ISet<ActivityDescriptor>` - Legacy support for manually registered activities
Key behaviors:
- Descriptors with `TenantId = null` or `TenantId = "*"` are stored in `_agnosticRegistry`
- Descriptors with any other `TenantId` are stored in the corresponding tenant's registry in `_tenantRegistries`
- `RefreshDescriptorsAsync()` updates only the affected tenant's registry, not the entire global dictionary
- Find methods **always prefer tenant-specific descriptors over agnostic ones**, even if agnostic has a higher version number
### 3. EF Core Query Filter
Update `SetTenantIdFilter` to return records where:
- `TenantId == dbContext.TenantId` (tenant-specific match), OR
- `TenantId == "*"` (tenant-agnostic records)
### 4. Entity Handlers
Update `ApplyTenantId` handler to:
- Preserve `TenantId = "*"` (don't overwrite tenant-agnostic entities)
- Only apply current tenant ID to entities with `TenantId = null`
**Important: This is a security-by-default design.** Entities with `null` tenant ID are **never** automatically converted to tenant-agnostic (`"*"`). They are always assigned to the current tenant context. To create tenant-agnostic database entities, developers **must explicitly** set `TenantId = "*"`. This prevents accidental data leakage across tenants.
### 5. Reserved Character Constraint
The asterisk character `"*"` is **reserved** and cannot be used as an actual tenant ID. Tenant creation and validation logic should reject any attempt to create a tenant with ID `"*"`.
## Consequences
### Positive
- **Explicit tenant-agnostic marking**: The `"*"` sentinel makes intent clear in code, logs, and database
- **Proper tenant isolation**: The three-dictionary architecture prevents tenant activation from wiping out other tenants' descriptors
- **No nullable handling**: Composite keys remain `(string TenantId, ...)` instead of `(string? TenantId, ...)`
- **Tenant precedence**: Tenant-specific descriptors always take precedence over agnostic ones, allowing tenants to override built-in activities
- **Dynamic tenant management**: Tenants can be activated and deactivated at runtime without affecting each other
- **Database efficiency**: Tenant-agnostic entities are stored once and accessible to all tenants
- **Clear SQL queries**: `WHERE TenantId = current_tenant OR TenantId = '*'` is more explicit than null checks
- **Thread safety**: Per-tenant dictionaries eliminate the need for `Interlocked.Exchange` and its race conditions
### Negative
- **Reserved character**: The `"*"` character cannot be used as an actual tenant ID (low impact, as tenant IDs are typically alphanumeric)
- **Two conventions**: Developers must understand the distinction between `"*"` (agnostic) and `""` (default tenant)
- **Migration complexity**: Existing systems using `null` for agnostic entities would need data migration
### Neutral
- Using a sentinel value for special cases is a common pattern in software architecture
- The distinction between default tenant and tenant-agnostic is fundamental to proper multitenancy design
- The three-dictionary architecture adds complexity but is necessary for correct tenant isolation
## Implementation Notes
### Semantic Flow: From Entity Creation to Query
Understanding how tenant IDs flow through the system is critical:
```
┌─────────────────────────────────────────────────────────────┐
│ Entity Creation / Deserialization │
├─────────────────────────────────────────────────────────────┤
│ TenantId = null → Not yet assigned │
│ TenantId = "*" → Explicitly agnostic │
│ TenantId = "" → Default tenant │
│ TenantId = "foo" → Specific tenant "foo" │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ApplyTenantId Handler (before DB save) │
├─────────────────────────────────────────────────────────────┤
│ TenantId = "*" → PRESERVED (agnostic) │
│ TenantId = null → SET to current tenant from context │
│ TenantId = "" → PRESERVED (default tenant) │
│ TenantId = "foo" → PRESERVED (specific tenant) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Database Storage │
├─────────────────────────────────────────────────────────────┤
│ TenantId = "*" → Stored as "*" (agnostic) │
│ TenantId = "" → Stored as "" (default tenant) │
│ TenantId = "foo" → Stored as "foo" (specific tenant) │
│ NOTE: No null values in DB after ApplyTenantId handler │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ SetTenantIdFilter (EF Core Query) │
├─────────────────────────────────────────────────────────────┤
│ Returns: TenantId == current_tenant OR TenantId == "*" │
│ Result: Tenant-specific records + agnostic records │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ActivityRegistry (In-Memory) │
├─────────────────────────────────────────────────────────────┤
│ null or "*" → _agnosticRegistry (shared) │
│ TenantId="" → _tenantRegistries[""] (default) │
│ TenantId=X → _tenantRegistries[X] (specific tenant X) │
└─────────────────────────────────────────────────────────────┘
```
**Key Points:**
- **`null` is transient**: It only exists during entity creation/deserialization before `ApplyTenantId` runs
- **`"*"` is permanent**: Once set, it's preserved and stored in the database as-is
- **`NormalizeTenantId()` converts `null` → `""`**: This ensures `null` becomes the default tenant, NOT agnostic
- **Database has no nulls**: After `ApplyTenantId` handler, all entities have non-null tenant IDs
- **EF Core filters check for `"*"`**: The query filter explicitly compares against the string `"*"`, not null
- **ActivityRegistry accepts both**: For flexibility, in-memory registry treats both `null` and `"*"` as agnostic
### ActivityRegistry Behavior
When `Find(string type)` is called:
1. First check the current tenant's registry for matching descriptors
2. If found, return the highest version from the tenant-specific registry
3. Only if no tenant-specific descriptor exists, fall back to the agnostic registry
4. This ensures tenant-specific customizations always take precedence
The `GetOrCreateRegistry()` method treats both `null` and `"*"` as agnostic:
```csharp
if (tenantId is null or Tenant.AgnosticTenantId)
return _agnosticRegistry;
```
This provides flexibility for in-memory operations where activity descriptors might temporarily have `null` tenant IDs before normalization.
### Activity Descriptors vs Database Entities: Different Rules
The system treats **in-memory activity descriptors** and **persistent database entities** differently for security and architectural reasons:
#### In-Memory Activity Descriptors (Ephemeral)
- Created on startup by activity providers
- **Built-in activities** (WriteLine, SetVariable, etc.): Created with `TenantId = null` by `ActivityDescriber`
- **Workflow-as-activities**: Created with `TenantId = definition.TenantId` by `WorkflowDefinitionActivityDescriptorFactory`
- `null` is acceptable here because descriptors are recreated on each startup and mapped to `_agnosticRegistry`
- No security risk: descriptors don't contain sensitive data, just metadata about activity types
#### Persistent Database Entities (WorkflowDefinition, etc.)
- Stored permanently in the database
- **Must explicitly set `TenantId = "*"`** to be tenant-agnostic
- `TenantId = null` is **never** converted to `"*"` - always assigned to current tenant
- **Security-by-default**: Prevents accidental data leakage across tenants
- A developer who forgets to set `TenantId` creates a tenant-specific entity, not a global one
**Example - Creating Tenant-Agnostic Workflow:**
```json
{
"tenantId": "*",
"name": "GlobalApprovalWorkflow",
"description": "Shared across all tenants",
"root": { ... }
}
```
**Why This Asymmetry Is Important:**
1. **Safety**: Database entities with null tenant ID default to current tenant (safe)
2. **Explicitness**: Tenant-agnostic entities must be intentional (require `"*"`)
3. **Different lifecycles**: Descriptors are ephemeral, entities are persistent
4. **Backward compatibility**: Built-in activities work without modification
### Workflow Import Behavior
When workflows are imported from providers (e.g., blob storage):
- Workflows without an explicit `tenantId` field in their JSON have `TenantId = null`
- During import, these are normalized to the current tenant ID via `NormalizeTenantId()` extension
- When saved to database, `ApplyTenantId` handler assigns the current tenant from context
- To create truly tenant-agnostic workflows, explicitly set `"tenantId": "*"` in the workflow JSON
- The `"*"` value will be preserved through import, save, and query operations
### Testing Considerations
- Component tests use the default tenant (`""`)
- Built-in activities use the agnostic marker (`"*"`)
- Tenant-specific tests should create explicit tenant contexts to verify proper isolation
- Unit tests should verify that `"*"` is preserved through save operations
- Integration tests should verify that `"*"` entities are returned for all tenant contexts

View file

@ -1,20 +1,22 @@
digraph {
node [shape=plaintext];
subgraph {
_1 [label="1. Record architecture decisions"; URL="0001-record-architecture-decisions.html"];
_2 [label="2. Fault Propagation from Child to Parent Activities"; URL="0002-fault-propagation-from-child-to-parent-activities.html"];
_1 -> _2 [style="dotted", weight=1];
_3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003-direct-bookmark-management-in-workflowexecutioncontext.html"];
_2 -> _3 [style="dotted", weight=1];
_4 [label="4. Activity Execution Snapshots"; URL="0004-activity-execution-snapshots.html"];
_3 -> _4 [style="dotted", weight=1];
_5 [label="5. Token-Centric Flowchart Execution Model"; URL="0005-token-centric-flowchart-execution-model.html"];
_4 -> _5 [style="dotted", weight=1];
_6 [label="6. Tenant Deleted Event"; URL="0006-tenant-deleted-event.html"];
_5 -> _6 [style="dotted", weight=1];
_7 [label="7. Adoption of Explicit Merge Modes for Flowchart Joins"; URL="0007-adoption-of-explicit-merge-modes-for-flowchart-joins.html"];
_6 -> _7 [style="dotted", weight=1];
_8 [label="8. Empty String as Default Tenant ID"; URL="0008-empty-string-as-default-tenant-id.html"];
_7 -> _8 [style="dotted", weight=1];
}
node [shape=plaintext];
subgraph {
_1 [label="1. Record architecture decisions"; URL="0001-record-architecture-decisions.html"];
_2 [label="2. Fault Propagation from Child to Parent Activities"; URL="0002-fault-propagation-from-child-to-parent-activities.html"];
_1 -> _2 [style="dotted", weight=1];
_3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003-direct-bookmark-management-in-workflowexecutioncontext.html"];
_2 -> _3 [style="dotted", weight=1];
_4 [label="4. Activity Execution Snapshots"; URL="0004-activity-execution-snapshots.html"];
_3 -> _4 [style="dotted", weight=1];
_5 [label="5. Token-Centric Flowchart Execution Model"; URL="0005-token-centric-flowchart-execution-model.html"];
_4 -> _5 [style="dotted", weight=1];
_6 [label="6. Tenant Deleted Event"; URL="0006-tenant-deleted-event.html"];
_5 -> _6 [style="dotted", weight=1];
_7 [label="7. Adoption of Explicit Merge Modes for Flowchart Joins"; URL="0007-adoption-of-explicit-merge-modes-for-flowchart-joins.html"];
_6 -> _7 [style="dotted", weight=1];
_8 [label="8. Empty String as Default Tenant ID"; URL="0008-empty-string-as-default-tenant-id.html"];
_7 -> _8 [style="dotted", weight=1];
_9 [label="9. Asterisk Sentinel Value for Tenant-Agnostic Entities"; URL="0009-asterisk-sentinel-value-for-tenant-agnostic-entities.html"];
_8 -> _9 [style="dotted", weight=1];
}
}

View file

@ -7,4 +7,5 @@
* [5. Token-Centric Flowchart Execution Model](0005-token-centric-flowchart-execution-model.md)
* [6. Tenant Deleted Event](0006-tenant-deleted-event.md)
* [7. Adoption of Explicit Merge Modes for Flowchart Joins](0007-adoption-of-explicit-merge-modes-for-flowchart-joins.md)
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)
* [9. Asterisk Sentinel Value for Tenant-Agnostic Entities](0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md)

View file

@ -31,7 +31,7 @@ using Microsoft.Extensions.Options;
// ReSharper disable RedundantAssignment
const bool useReadOnlyMode = false;
const bool useSignalR = false; // Disabled until Elsa Studio sends authenticated requests.
const bool useMultitenancy = false;
const bool useMultitenancy = true;
const bool disableVariableWrappers = false;
ObjectConverter.StrictMode = true;

View file

@ -41,8 +41,10 @@ public class CommandHandlerInvokerMiddleware(CommandMiddlewareDelegate next) : I
// Execute command.
var task = executeMethodWithReturnType.Invoke(strategy, [strategyContext]);
// Get the result of the task.
// Await the task to get the result without blocking.
var taskWithReturnType = typeof(Task<>).MakeGenericType(resultType);
var taskInstance = (Task)task!;
await taskInstance.ConfigureAwait(false);
var resultProperty = taskWithReturnType.GetProperty(nameof(Task<object>.Result))!;
context.Result = resultProperty.GetValue(task);

View file

@ -20,17 +20,18 @@ public class SignalManager
{
var taskCompletionSource = GetOrCreate(signal);
using var cancellationTokenSource = new CancellationTokenSource(millisecondsTimeout);
try
var delayTask = Task.Delay(millisecondsTimeout, cancellationTokenSource.Token);
var completedTask = await Task.WhenAny(taskCompletionSource.Task, delayTask);
if (completedTask == delayTask)
{
await Task.WhenAny(taskCompletionSource.Task, Task.Delay(millisecondsTimeout, cancellationTokenSource.Token));
cancellationTokenSource.Token.ThrowIfCancellationRequested();
_signals.TryRemove(signal, out _);
return await taskCompletionSource.Task;
}
catch (OperationCanceledException)
{
throw new TimeoutException($"Signal '{signal}' timed out after {millisecondsTimeout} milliseconds.");
}
cancellationTokenSource.Cancel();
_signals.TryRemove(signal, out _);
return await taskCompletionSource.Task;
}
public void Trigger(object signal, object? result = null)
@ -45,6 +46,6 @@ public class SignalManager
private TaskCompletionSource<object?> GetOrCreate(object eventName)
{
return _signals.GetOrAdd(eventName, _ => new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously));
return _signals.GetOrAdd(eventName, _ => new(TaskCreationOptions.RunContinuationsAsynchronously));
}
}

View file

@ -1,11 +0,0 @@
using Elsa.Common.Multitenancy;
namespace Elsa.Testing.Shared.Services;
public class TestTenantResolver : TenantResolverBase
{
protected override TenantResolverResult Resolve(TenantResolverContext context)
{
return AutoResolve("Tenant1");
}
}

View file

@ -1,4 +1,5 @@
using Elsa.Common;
using Elsa.Common.Multitenancy;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Services;
using Elsa.Extensions;
@ -180,5 +181,6 @@ public class ActivityTestFixture
services.AddSingleton<IActivitySchedulerFactory, ActivitySchedulerFactory>();
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, FakeWorkflowExecutionContextSchedulerStrategy>();
services.AddSingleton<IActivityExecutionContextSchedulerStrategy, FakeActivityExecutionContextSchedulerStrategy>();
services.AddSingleton<ITenantAccessor, DefaultTenantAccessor>();
}
}

View file

@ -2,6 +2,8 @@ namespace Elsa.Common.Multitenancy;
public interface ITenantAccessor
{
string TenantId { get; }
/// <summary>
/// Get the current <see cref="Multitenancy.Tenant"/>.
/// </summary>

View file

@ -15,6 +15,11 @@ public class Tenant : Entity
/// </summary>
public const string DefaultTenantId = "";
/// <summary>
/// The ID used for tenant-agnostic entities that are available to all tenants.
/// </summary>
public const string AgnosticTenantId = "*";
/// <summary>
/// Gets or sets the name.
/// </summary>

View file

@ -118,7 +118,12 @@ public class TenantTaskManager(RecurringTaskScheduleManager scheduleManager, ILo
{
logger.LogInformation(e, "Recurring task {TaskType} was cancelled", task.GetType().Name);
}
});
catch (Exception e)
{
// Log but don't rethrow - recurring tasks should not crash the host
logger.LogError(e, "Recurring task {TaskType} failed with an error", task.GetType().Name);
}
}, logger);
_scheduledTimers.Add(timer);
await task.StartAsync(cancellationToken);

View file

@ -7,6 +7,8 @@ public class DefaultTenantAccessor : ITenantAccessor
{
private static readonly AsyncLocal<Tenant?> CurrentTenantField = new();
public string TenantId => (Tenant?.Id).NormalizeTenantId();
/// <inheritdoc/>
public Tenant? Tenant
{

View file

@ -1,11 +1,12 @@
using Cronos;
using Microsoft.Extensions.Logging;
namespace Elsa.Common.RecurringTasks;
public class CronSchedule(ISystemClock systemClock, CronExpression expression) : ISchedule
{
public ScheduledTimer CreateTimer(Func<Task> action)
public ScheduledTimer CreateTimer(Func<Task> action, ILogger? logger = null)
{
return new ScheduledTimer(action, () => expression.GetNextOccurrence(systemClock.UtcNow.DateTime)!.Value - systemClock.UtcNow.DateTime);
return new ScheduledTimer(action, () => expression.GetNextOccurrence(systemClock.UtcNow.DateTime)!.Value - systemClock.UtcNow.DateTime, logger);
}
}

View file

@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Common.RecurringTasks;
public interface ISchedule
{
ScheduledTimer CreateTimer(Func<Task> action);
ScheduledTimer CreateTimer(Func<Task> action, ILogger? logger = null);
}

View file

@ -1,9 +1,11 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Common.RecurringTasks;
public class IntervalSchedule(TimeSpan interval) : ISchedule
{
public ScheduledTimer CreateTimer(Func<Task> action)
public ScheduledTimer CreateTimer(Func<Task> action, ILogger? logger = null)
{
return new ScheduledTimer(action, () => interval);
return new ScheduledTimer(action, () => interval, logger);
}
}

View file

@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Common.RecurringTasks;
public class ScheduledTimer : IDisposable, IAsyncDisposable
@ -5,18 +7,39 @@ public class ScheduledTimer : IDisposable, IAsyncDisposable
private readonly Func<Task> _action;
private readonly Func<TimeSpan> _interval;
private readonly Timer _timer;
private readonly ILogger? _logger;
public ScheduledTimer(Func<Task> action, Func<TimeSpan> interval)
public ScheduledTimer(Func<Task> action, Func<TimeSpan> interval, ILogger? logger = null)
{
_action = action;
_interval = interval;
_logger = logger;
_timer = new Timer(Callback, null, interval(), Timeout.InfiniteTimeSpan);
}
private async void Callback(object? state)
{
await _action();
_timer.Change(_interval(), Timeout.InfiniteTimeSpan);
try
{
await _action();
}
catch (Exception e)
{
// Swallow exception to prevent async void from crashing the process.
// Log unhandled exceptions here as a safeguard; calling code may have its own exception handling.
_logger?.LogError(e, "Unhandled exception in scheduled timer action");
}
finally
{
try
{
_timer.Change(_interval(), Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Timer was disposed, ignore.
}
}
}
public void Dispose()

View file

@ -47,10 +47,8 @@ public abstract class ElsaDbContextBase : DbContext, IElsaDbContextSchema
Schema = !string.IsNullOrWhiteSpace(_elsaDbContextOptions?.SchemaName) ? _elsaDbContextOptions.SchemaName : ElsaSchema;
var tenantAccessor = serviceProvider.GetService<ITenantAccessor>();
var tenantId = tenantAccessor?.Tenant?.Id;
if (!string.IsNullOrWhiteSpace(tenantId))
TenantId = tenantId.NullIfEmpty();
var tenantId = (tenantAccessor?.TenantId).NormalizeTenantId();
TenantId ??= tenantId;
}
/// <inheritdoc/>

View file

@ -1,5 +1,5 @@
using Elsa.Common.Entities;
using Elsa.Extensions;
using Elsa.Common.Multitenancy;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace Elsa.Persistence.EFCore.EntityHandlers;
@ -12,8 +12,16 @@ public class ApplyTenantId : IEntitySavingHandler
/// <inheritdoc />
public ValueTask HandleAsync(ElsaDbContextBase dbContext, EntityEntry entry, CancellationToken cancellationToken = default)
{
if (entry.Entity is Entity entity)
entity.TenantId = dbContext.TenantId.NullIfEmpty();
if (entry.Entity is Entity entity)
{
// Don't touch tenant-agnostic entities (marked with "*")
if (entity.TenantId == Tenant.AgnosticTenantId)
return default;
// Apply current tenant ID to entities without one
if (entity.TenantId == null && dbContext.TenantId != null)
entity.TenantId = dbContext.TenantId;
}
return default;
}

View file

@ -1,5 +1,6 @@
using System.Linq.Expressions;
using Elsa.Common.Entities;
using Elsa.Common.Multitenancy;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
@ -25,7 +26,7 @@ public class SetTenantIdFilter : IEntityModelCreatingHandler
{
var parameter = Expression.Parameter(clrType, "e");
// e => EF.Property<string>(e, "TenantId") == this.TenantId
// e => EF.Property<string>(e, "TenantId") == this.TenantId || EF.Property<string>(e, "TenantId") == "*"
var tenantIdProperty = Expression.Call(
typeof(EF),
nameof(EF.Property),
@ -37,7 +38,9 @@ public class SetTenantIdFilter : IEntityModelCreatingHandler
Expression.Constant(dbContext),
nameof(ElsaDbContextBase.TenantId));
var body = Expression.Equal(tenantIdProperty, tenantIdOnContext);
var equalityCheck = Expression.Equal(tenantIdProperty, tenantIdOnContext);
var agnosticCheck = Expression.Equal(tenantIdProperty, Expression.Constant(Tenant.AgnosticTenantId, typeof(string)));
var body = Expression.OrElse(equalityCheck, agnosticCheck);
return Expression.Lambda(body, parameter);
}

View file

@ -182,11 +182,19 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
}
// When doing a custom SQL query (Bulk Upsert), none of the installed query filters will be applied. Hence, we are assigning the current tenant ID explicitly.
var tenantId = serviceProvider.GetRequiredService<ITenantAccessor>().Tenant?.Id.NullIfEmpty();
var tenantId = serviceProvider.GetRequiredService<ITenantAccessor>().Tenant?.Id;
foreach (var entity in entityList)
{
if (entity is Entity entityWithTenant)
entityWithTenant.TenantId = tenantId;
{
// Don't touch tenant-agnostic entities (marked with "*")
if (entityWithTenant.TenantId == Tenant.AgnosticTenantId)
continue;
// Apply current tenant ID to entities without one
if (entityWithTenant.TenantId == null && tenantId != null)
entityWithTenant.TenantId = tenantId;
}
}
try

View file

@ -32,6 +32,6 @@ public class TenantAwareDbContextFactory<TDbContext>(
private void SetTenantId(TDbContext context)
{
if (context is ElsaDbContextBase elsaContext)
elsaContext.TenantId = tenantAccessor.Tenant?.Id.NullIfEmpty();
elsaContext.TenantId = tenantAccessor.Tenant?.Id;
}
}

View file

@ -0,0 +1,235 @@
// <auto-generated />
using System;
using Elsa.Persistence.EFCore.Modules.Management;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management
{
[DbContext(typeof(ManagementElsaDbContext))]
[Migration("20260131023442_ConvertNullTenantIdToEmptyString")]
partial class ConvertNullTenantIdToEmptyString
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Elsa")
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<byte[]>("BinaryData")
.HasColumnType("varbinary(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Data")
.HasColumnType("nvarchar(max)");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsLatest")
.HasColumnType("bit");
b.Property<bool>("IsPublished")
.HasColumnType("bit");
b.Property<bool>("IsReadonly")
.HasColumnType("bit");
b.Property<bool>("IsSystem")
.HasColumnType("bit");
b.Property<string>("MaterializerContext")
.HasColumnType("nvarchar(max)");
b.Property<string>("MaterializerName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("OriginalSource")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderName")
.HasColumnType("nvarchar(max)");
b.Property<string>("StringData")
.HasColumnType("nvarchar(max)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<string>("ToolVersion")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("UsableAsActivity")
.HasColumnType("bit");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("IsLatest")
.HasDatabaseName("IX_WorkflowDefinition_IsLatest");
b.HasIndex("IsPublished")
.HasDatabaseName("IX_WorkflowDefinition_IsPublished");
b.HasIndex("IsSystem")
.HasDatabaseName("IX_WorkflowDefinition_IsSystem");
b.HasIndex("Name")
.HasDatabaseName("IX_WorkflowDefinition_Name");
b.HasIndex("TenantId")
.HasDatabaseName("IX_WorkflowDefinition_TenantId");
b.HasIndex("UsableAsActivity")
.HasDatabaseName("IX_WorkflowDefinition_UsableAsActivity");
b.HasIndex("Version")
.HasDatabaseName("IX_WorkflowDefinition_Version");
b.HasIndex("DefinitionId", "Version")
.IsUnique()
.HasDatabaseName("IX_WorkflowDefinition_DefinitionId_Version");
b.ToTable("WorkflowDefinitions", "Elsa");
});
modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowInstance", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("CorrelationId")
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Data")
.HasColumnType("nvarchar(max)");
b.Property<string>("DataCompressionAlgorithm")
.HasColumnType("nvarchar(max)");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("DefinitionVersionId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("datetimeoffset");
b.Property<int>("IncidentCount")
.HasColumnType("int");
b.Property<bool>("IsExecuting")
.HasColumnType("bit");
b.Property<bool>("IsSystem")
.HasColumnType("bit");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("ParentWorkflowInstanceId")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("SubStatus")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CorrelationId")
.HasDatabaseName("IX_WorkflowInstance_CorrelationId");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_WorkflowInstance_CreatedAt");
b.HasIndex("DefinitionId")
.HasDatabaseName("IX_WorkflowInstance_DefinitionId");
b.HasIndex("FinishedAt")
.HasDatabaseName("IX_WorkflowInstance_FinishedAt");
b.HasIndex("IsExecuting")
.HasDatabaseName("IX_WorkflowInstance_IsExecuting");
b.HasIndex("IsSystem")
.HasDatabaseName("IX_WorkflowInstance_IsSystem");
b.HasIndex("Name")
.HasDatabaseName("IX_WorkflowInstance_Name");
b.HasIndex("Status")
.HasDatabaseName("IX_WorkflowInstance_Status");
b.HasIndex("SubStatus")
.HasDatabaseName("IX_WorkflowInstance_SubStatus");
b.HasIndex("TenantId")
.HasDatabaseName("IX_WorkflowInstance_TenantId");
b.HasIndex("UpdatedAt")
.HasDatabaseName("IX_WorkflowInstance_UpdatedAt");
b.HasIndex("Status", "DefinitionId")
.HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId");
b.HasIndex("Status", "SubStatus")
.HasDatabaseName("IX_WorkflowInstance_Status_SubStatus");
b.HasIndex("SubStatus", "DefinitionId")
.HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId");
b.HasIndex("Status", "SubStatus", "DefinitionId", "Version")
.HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version");
b.ToTable("WorkflowInstances", "Elsa");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management
{
/// <inheritdoc />
public partial class ConvertNullTenantIdToEmptyString : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public ConvertNullTenantIdToEmptyString(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Convert null TenantId values to empty string for default tenant entities
// This aligns with ADR-0008 (empty string = default tenant) and ADR-0009 (null = tenant-agnostic)
// All existing null values are assumed to be default tenant data from before the tenant-agnostic feature was introduced
migrationBuilder.Sql($@"
UPDATE [{_schema.Schema}].[WorkflowDefinitions]
SET TenantId = ''
WHERE TenantId IS NULL
");
migrationBuilder.Sql($@"
UPDATE [{_schema.Schema}].[WorkflowInstances]
SET TenantId = ''
WHERE TenantId IS NULL
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Revert empty string TenantId values back to null
// Note: This may cause issues with the tenant-agnostic feature if run after new tenant-agnostic entities are created
migrationBuilder.Sql($@"
UPDATE [{_schema.Schema}].[WorkflowDefinitions]
SET TenantId = NULL
WHERE TenantId = ''
");
migrationBuilder.Sql($@"
UPDATE [{_schema.Schema}].[WorkflowInstances]
SET TenantId = NULL
WHERE TenantId = ''
");
}
}
}

View file

@ -10,6 +10,8 @@ namespace Elsa.Workflows.Models;
[DebuggerDisplay("{TypeName}")]
public class ActivityDescriptor
{
public string? TenantId { get; set; } // Null means tenant-agnostic.
/// <summary>
/// The fully qualified name of the activity type.
/// </summary>

View file

@ -0,0 +1,43 @@
using System.Collections.Concurrent;
namespace Elsa.Workflows.Models;
/// <summary>
/// Holds the per-tenant activity descriptor dictionaries that back the activity registry.
/// </summary>
/// <remarks>
/// <para>
/// This model represents the value stored in the tenant-level registry dictionary described in ADR-0009's
/// three-dictionary architecture. The outermost dictionary typically maps a tenant identifier (or a value
/// representing tenant-agnostic scope) to an instance of <see cref="TenantRegistryData"/>. Within this class,
/// the <see cref="ActivityDescriptors"/> and <see cref="ProvidedActivityDescriptors"/> properties form the
/// inner dictionaries that index activity descriptors for that specific tenant or for tenant-agnostic activities.
/// </para>
/// <para>
/// By encapsulating these dictionaries, the registry can manage activity descriptors per tenant while maintaining
/// a consistent lookup and invalidation strategy across the entire system.
/// </para>
/// </remarks>
public class TenantRegistryData
{
/// <summary>
/// Primary index of activity descriptors for this tenant (or for tenant-agnostic scope).
/// </summary>
/// <remarks>
/// The key is a composite of the activity <c>Type</c> (a logical activity type identifier) and its
/// <c>Version</c>. This allows efficient lookup of a specific activity descriptor by type and version,
/// which is the most common access pattern when compiling or executing workflows.
/// </remarks>
public ConcurrentDictionary<(string Type, int Version), ActivityDescriptor> ActivityDescriptors { get; } = new();
/// <summary>
/// Secondary index of activity descriptors grouped by their provider type.
/// </summary>
/// <remarks>
/// This dictionary maps a provider <see cref="Type"/> (for example, an activity provider implementation)
/// to the collection of <see cref="ActivityDescriptor"/> instances contributed by that provider for this
/// tenant. It complements <see cref="ActivityDescriptors"/> by enabling provider-centric operations such as
/// refreshing, removing, or re-registering all descriptors originating from a given provider.
/// </remarks>
public ConcurrentDictionary<Type, ICollection<ActivityDescriptor>> ProvidedActivityDescriptors { get; } = new();
}

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Elsa.Common.Multitenancy;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Logging;
@ -7,44 +8,150 @@ using Microsoft.Extensions.Logging;
namespace Elsa.Workflows;
/// <inheritdoc />
public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<IActivityDescriptorModifier> modifiers, ILogger<ActivityRegistry> logger) : IActivityRegistry
public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<IActivityDescriptorModifier> modifiers, ITenantAccessor tenantAccessor, ILogger<ActivityRegistry> logger) : IActivityRegistry
{
// Legacy support for manually registered activities
private readonly ISet<ActivityDescriptor> _manualActivityDescriptors = new HashSet<ActivityDescriptor>();
private ConcurrentDictionary<Type, ICollection<ActivityDescriptor>> _providedActivityDescriptors = new();
private ConcurrentDictionary<(string Type, int Version), ActivityDescriptor> _activityDescriptors = new();
// 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();
/// <inheritdoc />
public void Add(Type providerType, ActivityDescriptor descriptor) => Add(descriptor, GetOrCreateDescriptors(providerType));
public void Add(Type providerType, ActivityDescriptor descriptor)
{
var registry = GetOrCreateRegistry(descriptor.TenantId);
var providerDescriptors = GetOrCreateProviderDescriptors(registry, providerType);
Add(descriptor, registry.ActivityDescriptors, providerDescriptors);
}
/// <inheritdoc />
public void Remove(Type providerType, ActivityDescriptor descriptor)
{
_providedActivityDescriptors[providerType].Remove(descriptor);
_activityDescriptors.Remove((descriptor.TypeName, descriptor.Version), out _);
var registry = GetOrCreateRegistry(descriptor.TenantId);
if (registry.ProvidedActivityDescriptors.TryGetValue(providerType, out var providerDescriptors))
{
providerDescriptors.Remove(descriptor);
registry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out _);
}
}
/// <inheritdoc />
public IEnumerable<ActivityDescriptor> ListAll() => _activityDescriptors.Values;
public IEnumerable<ActivityDescriptor> ListAll()
{
var currentTenantId = tenantAccessor.TenantId;
// Get descriptors from current tenant's registry
var tenantDescriptors = _tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry)
? tenantRegistry.ActivityDescriptors.Values
: Enumerable.Empty<ActivityDescriptor>();
// Get descriptors from agnostic registry
var agnosticDescriptors = _agnosticRegistry.ActivityDescriptors.Values;
return tenantDescriptors.Concat(agnosticDescriptors);
}
/// <inheritdoc />
public IEnumerable<ActivityDescriptor> ListByProvider(Type providerType) => _providedActivityDescriptors.TryGetValue(providerType, out var descriptors) ? descriptors : ArraySegment<ActivityDescriptor>.Empty;
public IEnumerable<ActivityDescriptor> ListByProvider(Type providerType)
{
var currentTenantId = tenantAccessor.TenantId;
// Get descriptors from current tenant's registry
var tenantDescriptors = _tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry) &&
tenantRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var tenantProviderDescriptors)
? tenantProviderDescriptors
: Enumerable.Empty<ActivityDescriptor>();
// Get descriptors from agnostic registry
var agnosticDescriptors = _agnosticRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var agnosticProviderDescriptors)
? agnosticProviderDescriptors
: Enumerable.Empty<ActivityDescriptor>();
return tenantDescriptors.Concat(agnosticDescriptors);
}
/// <inheritdoc />
public ActivityDescriptor? Find(string type) => _activityDescriptors.Values.Where(x => x.TypeName == type).MaxBy(x => x.Version);
public ActivityDescriptor? Find(string type)
{
var currentTenantId = tenantAccessor.TenantId;
// Always prefer tenant-specific descriptors over tenant-agnostic ones
// 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)
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);
}
/// <inheritdoc />
public ActivityDescriptor? Find(string type, int version) => _activityDescriptors.TryGetValue((type, version), out var descriptor) ? descriptor : null;
public ActivityDescriptor? Find(string type, int version)
{
var currentTenantId = tenantAccessor.TenantId;
// Check current tenant's registry first
if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry) &&
tenantRegistry.ActivityDescriptors.TryGetValue((type, version), out var tenantDescriptor))
{
return tenantDescriptor;
}
// Fall back to agnostic registry
return _agnosticRegistry.ActivityDescriptors.TryGetValue((type, version), out var agnosticDescriptor)
? agnosticDescriptor
: null;
}
/// <inheritdoc />
public ActivityDescriptor? Find(Func<ActivityDescriptor, bool> predicate) => _activityDescriptors.Values.FirstOrDefault(predicate);
public ActivityDescriptor? Find(Func<ActivityDescriptor, bool> predicate)
{
var currentTenantId = tenantAccessor.TenantId;
// Check current tenant's registry first
if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry))
{
var tenantMatch = tenantRegistry.ActivityDescriptors.Values.FirstOrDefault(predicate);
if (tenantMatch != null) return tenantMatch;
}
// Fall back to agnostic registry
return _agnosticRegistry.ActivityDescriptors.Values.FirstOrDefault(predicate);
}
/// <inheritdoc />
public IEnumerable<ActivityDescriptor> FindMany(Func<ActivityDescriptor, bool> predicate) => _activityDescriptors.Values.Where(predicate);
public IEnumerable<ActivityDescriptor> FindMany(Func<ActivityDescriptor, bool> predicate)
{
var currentTenantId = tenantAccessor.TenantId;
// Get descriptors from current tenant's registry
var tenantDescriptors = _tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry)
? tenantRegistry.ActivityDescriptors.Values.Where(predicate)
: Enumerable.Empty<ActivityDescriptor>();
// Get descriptors from agnostic registry
var agnosticDescriptors = _agnosticRegistry.ActivityDescriptors.Values.Where(predicate);
return tenantDescriptors.Concat(agnosticDescriptors);
}
/// <inheritdoc />
public void Register(ActivityDescriptor descriptor)
{
Add(GetType(), descriptor);
var registry = GetOrCreateRegistry(descriptor.TenantId);
var providerDescriptors = GetOrCreateProviderDescriptors(registry, GetType());
Add(descriptor, registry.ActivityDescriptors, providerDescriptors);
}
/// <inheritdoc />
@ -52,12 +159,14 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
{
var activityTypeName = ActivityTypeNameHelper.GenerateTypeName(activityType);
if (_activityDescriptors.Values.Any(x => x.TypeName == activityTypeName))
// Check if already registered in any registry
if (ListAll().Any(x => x.TypeName == activityTypeName))
return;
var activityDescriptor = await activityDescriber.DescribeActivityAsync(activityType, cancellationToken);
Add(activityDescriptor, _activityDescriptors, _manualActivityDescriptors);
var registry = GetOrCreateRegistry(activityDescriptor.TenantId);
Add(activityDescriptor, registry.ActivityDescriptors, _manualActivityDescriptors);
_manualActivityDescriptors.Add(activityDescriptor);
}
@ -74,41 +183,45 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
/// <inheritdoc />
public async Task RefreshDescriptorsAsync(IEnumerable<IActivityProvider> activityProviders, CancellationToken cancellationToken = default)
{
var providersDictionary = new ConcurrentDictionary<Type, ICollection<ActivityDescriptor>>();
var activityDescriptors = new ConcurrentDictionary<(string Type, int Version), ActivityDescriptor>(_activityDescriptors);
foreach (var activityProvider in activityProviders)
{
var descriptors = (await activityProvider.GetDescriptorsAsync(cancellationToken)).ToList();
var providerDescriptors = new List<ActivityDescriptor>();
providersDictionary[activityProvider.GetType()] = providerDescriptors;
foreach (var descriptor in descriptors)
{
Add(descriptor, activityDescriptors, providerDescriptors);
}
}
Interlocked.Exchange(ref _activityDescriptors, activityDescriptors);
Interlocked.Exchange(ref _providedActivityDescriptors, providersDictionary);
foreach (var activityProvider in activityProviders)
await RefreshDescriptorsAsync(activityProvider, cancellationToken);
}
public async Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default)
{
var providersDictionary = new ConcurrentDictionary<Type, ICollection<ActivityDescriptor>>(_providedActivityDescriptors);
var activityDescriptors = new ConcurrentDictionary<(string Type, int Version), ActivityDescriptor>(_activityDescriptors);
var providerType = activityProvider.GetType();
// Get new descriptors from provider
var descriptors = (await activityProvider.GetDescriptorsAsync(cancellationToken)).ToList();
var providerDescriptors = new List<ActivityDescriptor>();
providersDictionary[activityProvider.GetType()] = providerDescriptors;
foreach (var descriptor in descriptors)
Add(descriptor, activityDescriptors, providerDescriptors);
// 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));
Interlocked.Exchange(ref _activityDescriptors, activityDescriptors);
Interlocked.Exchange(ref _providedActivityDescriptors, providersDictionary);
}
foreach (var group in descriptorsByTenant)
{
var tenantId = group.Key;
var registry = GetOrCreateRegistry(tenantId);
private void Add(ActivityDescriptor descriptor, ICollection<ActivityDescriptor> target)
{
Add(descriptor, _activityDescriptors, target);
// Remove old descriptors for this provider from this tenant's registry
if (registry.ProvidedActivityDescriptors.TryGetValue(providerType, out var oldDescriptors))
{
foreach (var oldDescriptor in oldDescriptors.ToList())
{
registry.ActivityDescriptors.TryRemove((oldDescriptor.TypeName, oldDescriptor.Version), out _);
}
}
// Add new descriptors for this tenant
var providerDescriptors = new List<ActivityDescriptor>();
foreach (var descriptor in group)
{
Add(descriptor, registry.ActivityDescriptors, providerDescriptors);
}
// Update the provider's descriptor list in this registry
registry.ProvidedActivityDescriptors[providerType] = providerDescriptors;
}
}
private void Add(ActivityDescriptor? descriptor, ConcurrentDictionary<(string Type, int Version), ActivityDescriptor> activityDescriptors, ICollection<ActivityDescriptor> providerDescriptors)
@ -129,7 +242,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
providerDescriptors.Remove(existingDescriptor);
// Log a warning.
logger.LogWarning("Activity descriptor {ActivityType} v{ActivityVersion} was already registered. Replacing with new descriptor", descriptor.TypeName, descriptor.Version);
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;
@ -139,29 +252,67 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable<
/// <inheritdoc />
public void Clear()
{
_activityDescriptors.Clear();
_providedActivityDescriptors.Clear();
_tenantRegistries.Clear();
_agnosticRegistry.ActivityDescriptors.Clear();
_agnosticRegistry.ProvidedActivityDescriptors.Clear();
}
/// <inheritdoc />
public void ClearProvider(Type providerType)
{
var descriptors = ListByProvider(providerType).ToList();
var currentTenantId = tenantAccessor.TenantId;
foreach (var descriptor in descriptors)
_activityDescriptors.Remove((descriptor.TypeName, descriptor.Version), out _);
// Clear from current tenant's registry
if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry)
&& tenantRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var descriptors))
{
foreach (var descriptor in descriptors.ToList())
tenantRegistry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out _);
_providedActivityDescriptors.Remove(providerType, out _);
tenantRegistry.ProvidedActivityDescriptors.TryRemove(providerType, out _);
}
// Clear from agnostic registry
if (_agnosticRegistry.ProvidedActivityDescriptors.TryGetValue(providerType, out var agnosticDescriptors))
{
foreach (var descriptor in agnosticDescriptors.ToList())
_agnosticRegistry.ActivityDescriptors.TryRemove((descriptor.TypeName, descriptor.Version), out _);
_agnosticRegistry.ProvidedActivityDescriptors.TryRemove(providerType, out _);
}
}
private ICollection<ActivityDescriptor> GetOrCreateDescriptors(Type provider)
/// <summary>
/// Clears all activity descriptors for a specific tenant. Useful when a tenant is deactivated.
/// </summary>
internal void ClearTenant(string tenantId)
{
if (_providedActivityDescriptors.TryGetValue(provider, out var descriptors))
return descriptors;
descriptors = new List<ActivityDescriptor>();
_providedActivityDescriptors[provider] = descriptors;
return descriptors;
_tenantRegistries.TryRemove(tenantId, out _);
}
}
private TenantRegistryData GetOrCreateRegistry(string? tenantId)
{
// Null or agnostic tenant ID goes to agnostic registry
if (tenantId is null or Tenant.AgnosticTenantId)
return _agnosticRegistry;
// Get or create tenant-specific registry
return _tenantRegistries.GetOrAdd(tenantId, _ => new());
}
private ICollection<ActivityDescriptor> GetOrCreateProviderDescriptors(TenantRegistryData registry, Type providerType)
{
return registry.ProvidedActivityDescriptors.GetOrAdd(providerType, _ => new List<ActivityDescriptor>());
}
/// <summary>
/// Normalizes tenant ID for grouping purposes.
/// Converts null to "*" so that both null and "*" descriptors are grouped together,
/// avoiding redundant processing of the agnostic registry.
/// </summary>
private static string? NormalizeTenantIdForGrouping(string? tenantId)
{
// Normalize null to "*" so both map to the same group
return tenantId ?? Tenant.AgnosticTenantId;
}
}

View file

@ -1,4 +1,3 @@
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Models;
@ -10,11 +9,8 @@ public class WorkflowDefinitionActivityDescriptorFactory
{
public ActivityDescriptor CreateDescriptor(WorkflowDefinition definition, WorkflowDefinition? latestPublishedDefinition = null)
{
var baseName = definition.Name!.Pascalize();
var tenantId = definition.TenantId.NormalizeTenantId();
// Include tenant ID in type name for non-default tenants to ensure uniqueness across tenants
var typeName = string.IsNullOrEmpty(tenantId) ? baseName : $"{tenantId}:{baseName}";
var typeName = definition.Name!.Pascalize();
var tenantId = definition.TenantId;
var ports = definition.Outcomes.Select(outcome => new Port
{
@ -36,6 +32,7 @@ public class WorkflowDefinitionActivityDescriptorFactory
return new()
{
TenantId = tenantId,
TypeName = typeName,
ClrType = typeof(WorkflowDefinitionActivity),
Name = typeName,

View file

@ -137,7 +137,7 @@ public class CachingWorkflowDefinitionStore(IWorkflowDefinitionStore decoratedSt
private async Task<T?> GetOrCreateAsync<T>(string key, Func<Task<T>> factory)
{
var tenantId = tenantAccessor.Tenant?.Id;
var tenantId = tenantAccessor.TenantId;
var tenantIdPrefix = !string.IsNullOrEmpty(tenantId) ? $"{tenantId}:" : string.Empty;
var internalKey = $"{tenantIdPrefix}{typeof(T).Name}:{key}";
return await cacheManager.GetOrCreateAsync(internalKey, async entry =>

View file

@ -1,5 +1,3 @@
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using Elsa.Workflows.Management.Materializers;
using Elsa.Workflows.Runtime.Features;
using Elsa.Workflows.Runtime.Options;
@ -15,7 +13,6 @@ namespace Elsa.Workflows.Runtime.Providers;
public class ClrWorkflowsProvider(
IOptions<RuntimeOptions> options,
IWorkflowBuilderFactory workflowBuilderFactory,
ITenantAccessor tenantAccessor,
IServiceProvider serviceProvider) : IWorkflowsProvider
{
/// <inheritdoc />
@ -34,20 +31,17 @@ public class ClrWorkflowsProvider(
var builder = workflowBuilderFactory.CreateBuilder();
var workflowBuilder = await workflowFactory(serviceProvider);
var workflowBuilderType = workflowBuilder.GetType();
var tenant = tenantAccessor.Tenant;
var tenantPrefix = !string.IsNullOrEmpty(tenant?.Id) ? $"{tenant.Id}:" : string.Empty;
await workflowBuilder.BuildAsync(builder, cancellationToken);
var workflow = await builder.BuildWorkflowAsync(cancellationToken);
var versionSuffix = $"v{workflow.Version}";
var definitionId = string.IsNullOrEmpty(workflow.Identity.DefinitionId) ? tenantPrefix + workflowBuilderType.Name : $"{tenantPrefix}{workflow.Identity.DefinitionId}";
var id = string.IsNullOrEmpty(workflow.Identity.Id) ? $"{tenantPrefix}{workflowBuilderType.Name}:{versionSuffix}" : $"{tenantPrefix}{workflow.Identity.Id}";
var tenantId = string.IsNullOrEmpty(workflow.Identity.TenantId) ? tenant?.Id : workflow.Identity.TenantId;
var definitionId = string.IsNullOrEmpty(workflow.Identity.DefinitionId) ? workflowBuilderType.Name : $"{workflow.Identity.DefinitionId}";
var id = string.IsNullOrEmpty(workflow.Identity.Id) ? $"{workflowBuilderType.Name}:{versionSuffix}" : $"{workflow.Identity.Id}";
workflow.Identity = workflow.Identity with
{
Id = id,
DefinitionId = definitionId,
TenantId = tenantId.NormalizeTenantId()
TenantId = workflow.Identity.TenantId
};
var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType());

View file

@ -68,8 +68,11 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
foreach (var result in results)
{
// Only import workflows belonging to the current tenant.
if (result.Workflow.Identity.TenantId.NormalizeTenantId() != currentTenantId)
// Normalize tenant IDs for comparison (null becomes empty string)
var definitionTenantId = result.Workflow.Identity.TenantId.NormalizeTenantId();
// Only import workflows belonging to the current tenant or tenant-agnostic workflows (TenantId = "*").
if (definitionTenantId != currentTenantId && definitionTenantId != Tenant.AgnosticTenantId)
{
_logger.LogDebug(
"Skipping adding workflow {WorkflowId} from provider {Provider} because it belongs to tenant '{WorkflowTenantId}' but current tenant is '{CurrentTenantId}'",
@ -187,14 +190,18 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
await UpdateIsLatest();
await UpdateIsPublished();
// Determine the tenant ID for the workflow definition
// If the workflow has no tenant ID, use the current tenant (normalized to handle null -> "")
var workflowTenantId = workflow.Identity.TenantId ?? (_tenantAccessor.Tenant?.Id).NormalizeTenantId();
var workflowDefinition = existingDefinitionVersion ?? new WorkflowDefinition
{
DefinitionId = workflow.Identity.DefinitionId,
Id = workflow.Identity.Id,
Version = workflow.Identity.Version,
TenantId = workflow.Identity.TenantId,
TenantId = workflowTenantId,
};
workflowDefinition.Description = workflow.WorkflowMetadata.Description;
workflowDefinition.Name = workflow.WorkflowMetadata.Name;
workflowDefinition.ToolVersion = workflow.WorkflowMetadata.ToolVersion;

View file

@ -1,18 +1,32 @@
using Elsa.Common.Multitenancy;
using Elsa.Workflows.ComponentTests.Fixtures;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.ComponentTests.Abstractions;
[Collection(nameof(AppCollection))]
public abstract class AppComponentTest(App app) : IDisposable
public abstract class AppComponentTest : IDisposable
{
protected WorkflowServer WorkflowServer { get; } = app.WorkflowServer;
protected Cluster Cluster { get; } = app.Cluster;
protected Infrastructure Infrastructure { get; } = app.Infrastructure;
protected IServiceScope Scope { get; } = app.WorkflowServer.Services.CreateScope();
protected WorkflowServer WorkflowServer { get; }
protected Cluster Cluster { get; }
protected Infrastructure Infrastructure { get; }
protected IServiceScope Scope { get; }
private readonly IDisposable _tenantScope;
protected AppComponentTest(App app)
{
WorkflowServer = app.WorkflowServer;
Cluster = app.Cluster;
Infrastructure = app.Infrastructure;
Scope = app.WorkflowServer.Services.CreateScope();
var tenantAccessor = Scope.ServiceProvider.GetRequiredService<ITenantAccessor>();
_tenantScope = tenantAccessor.PushContext(new Tenant { Id = string.Empty, Name = "Default" });
}
void IDisposable.Dispose()
{
_tenantScope.Dispose();
Scope.Dispose();
OnDispose();
}

View file

@ -17,6 +17,10 @@ using Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities;
using Elsa.Workflows.ComponentTests.WorkflowProviders;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime.Distributed.Extensions;
using Elsa.Tenants;
using Elsa.Tenants.Extensions;
using Elsa.Common.Features;
using Elsa.Workflows.ComponentTests.Services;
using FluentStorage;
using JetBrains.Annotations;
using Medallion.Threading;
@ -126,6 +130,15 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
{
http.UseCache();
});
// Ensure a consistent tenant context for tests.
elsa.Configure<MultitenancyFeature>(feature => feature.UseTenantsProvider(_ => new TestTenantsProvider(string.Empty, "Tenant1", "Tenant2", "Tenant3")));
elsa.UseTenants(tenants =>
{
tenants.ConfigureMultitenancy(options =>
options.TenantResolverPipelineBuilder = new TenantResolverPipelineBuilder()
.Append<ComponentTestTenantResolver>());
});
};
}

View file

@ -0,0 +1,15 @@
using Elsa.Common.Multitenancy;
namespace Elsa.Workflows.ComponentTests.Services;
/// <summary>
/// A tenant resolver for component tests that resolves to the default/empty tenant.
/// </summary>
public class ComponentTestTenantResolver : TenantResolverBase
{
protected override TenantResolverResult Resolve(TenantResolverContext context)
{
// Resolve to empty string (default tenant) to match workflow definitions without explicit tenants
return AutoResolve(Tenant.DefaultTenantId);
}
}

View file

@ -39,6 +39,14 @@ public class DeleteWorkflowTests : AppComponentTest
var workflowDefinitionManager = _scope1.ServiceProvider.GetRequiredService<IWorkflowDefinitionManager>();
var deletedCount = await workflowDefinitionManager.DeleteByDefinitionIdAsync(Workflows.DeleteWorkflow.DefinitionId);
Assert.True(deletedCount > 0, "Expected workflow definition to be deleted.");
var store = _scope1.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
var t1 = await store.FindAsync(new WorkflowDefinitionFilter
{
DefinitionId = Workflows.DeleteWorkflow.DefinitionId
});
Assert.Null(t1);
// Force a refresh of the activity registry to ensure it reflects the deletion
var activityRegistry = _scope1.ServiceProvider.GetRequiredService<IActivityRegistry>();

View file

@ -12,7 +12,6 @@ public class DeleteWorkflow : WorkflowBase
{
builder.Name = Type;
builder.WithDefinitionId(DefinitionId);
builder.WithTenantId("Tenant1"); // Use Tenant1 to match the test environment tenant
builder.WorkflowOptions.UsableAsActivity = true;
builder.Root = new Sequence
{

View file

@ -0,0 +1,160 @@
using Elsa.Common.Multitenancy;
using Elsa.Workflows;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Elsa.Workflows.Core.UnitTests.Services;
/// <summary>
/// Unit tests for ActivityRegistry, specifically testing multi-tenant descriptor resolution logic.
/// </summary>
public class ActivityRegistryTests
{
private const string TestActivityType = "TestActivity";
private const string CurrentTenant = "tenant1";
private readonly ITenantAccessor _tenantAccessor;
private readonly IActivityDescriber _activityDescriber;
private readonly ILogger<ActivityRegistry> _logger;
private readonly ActivityRegistry _registry;
public ActivityRegistryTests()
{
_tenantAccessor = Substitute.For<ITenantAccessor>();
_activityDescriber = Substitute.For<IActivityDescriber>();
_logger = Substitute.For<ILogger<ActivityRegistry>>();
_registry = new ActivityRegistry(_activityDescriber, Array.Empty<IActivityDescriptorModifier>(), _tenantAccessor, _logger);
// Set default tenant for all tests
_tenantAccessor.TenantId.Returns(CurrentTenant);
}
private ActivityDescriptor CreateDescriptor(string typeName, int version, string? tenantId) =>
new()
{
TypeName = typeName,
Version = version,
TenantId = tenantId,
Kind = ActivityKind.Action
};
private void RegisterDescriptors(params ActivityDescriptor[] descriptors)
{
foreach (var descriptor in descriptors)
_registry.Register(descriptor);
}
private static void AssertDescriptor(ActivityDescriptor? result, string? expectedTenantId, int expectedVersion)
{
Assert.NotNull(result);
Assert.Equal(expectedTenantId, result.TenantId);
Assert.Equal(expectedVersion, result.Version);
}
[Fact]
public void Find_TenantSpecificPreferredOverTenantAgnostic_WhenBothExist()
{
// Arrange
var tenantSpecific = CreateDescriptor(TestActivityType, 1, CurrentTenant);
var tenantAgnostic = CreateDescriptor(TestActivityType, 2, Tenant.AgnosticTenantId); // Higher version
RegisterDescriptors(tenantSpecific, tenantAgnostic);
// Act
var result = _registry.Find(TestActivityType);
// Assert - tenant-specific should be preferred even though it has a lower version
AssertDescriptor(result, CurrentTenant, 1);
}
[Fact]
public void Find_ReturnsTenantAgnostic_WhenNoTenantSpecificExists()
{
// Arrange
var tenantAgnostic = CreateDescriptor(TestActivityType, 1, Tenant.AgnosticTenantId);
RegisterDescriptors(tenantAgnostic);
// Act
var result = _registry.Find(TestActivityType);
// Assert
AssertDescriptor(result, Tenant.AgnosticTenantId, 1);
}
[Theory]
[InlineData(1, 2, 3, 3)] // Multiple versions, expect highest
[InlineData(3, 1, 2, 3)] // Out of order registration
[InlineData(1, 1, 1, 1)] // Same version multiple times
public void Find_ReturnsHighestVersionTenantSpecific_WhenMultipleTenantSpecificExist(int v1, int v2, int v3, int expectedVersion)
{
// Arrange
var descriptors = new[]
{
CreateDescriptor(TestActivityType, v1, CurrentTenant),
CreateDescriptor(TestActivityType, v2, CurrentTenant),
CreateDescriptor(TestActivityType, v3, CurrentTenant)
};
RegisterDescriptors(descriptors);
// Act
var result = _registry.Find(TestActivityType);
// Assert
AssertDescriptor(result, CurrentTenant, expectedVersion);
}
[Theory]
[InlineData(1, 2, 3, 3)] // Multiple versions, expect highest
[InlineData(3, 1, 2, 3)] // Out of order registration
[InlineData(1, 1, 1, 1)] // Same version multiple times
public void Find_ReturnsHighestVersionTenantAgnostic_WhenMultipleTenantAgnosticExist(int v1, int v2, int v3, int expectedVersion)
{
// Arrange
var descriptors = new[]
{
CreateDescriptor(TestActivityType, v1, Tenant.AgnosticTenantId),
CreateDescriptor(TestActivityType, v2, Tenant.AgnosticTenantId),
CreateDescriptor(TestActivityType, v3, Tenant.AgnosticTenantId)
};
RegisterDescriptors(descriptors);
// Act
var result = _registry.Find(TestActivityType);
// Assert
AssertDescriptor(result, Tenant.AgnosticTenantId, expectedVersion);
}
[Fact]
public void Find_ReturnsNull_WhenNoMatchingDescriptorsExist()
{
// Arrange
var otherDescriptor = CreateDescriptor("OtherActivity", 1, CurrentTenant);
RegisterDescriptors(otherDescriptor);
// Act
var result = _registry.Find("NonExistentActivity");
// Assert
Assert.Null(result);
}
[Fact]
public void Find_IgnoresOtherTenantDescriptors_OnlyReturnsCurrentTenantOrAgnostic()
{
// Arrange
var descriptors = new[]
{
CreateDescriptor(TestActivityType, 1, CurrentTenant),
CreateDescriptor(TestActivityType, 5, "tenant2"), // Much higher version but wrong tenant
CreateDescriptor(TestActivityType, 2, Tenant.AgnosticTenantId)
};
RegisterDescriptors(descriptors);
// Act
var result = _registry.Find(TestActivityType);
// Assert - should return tenant1 descriptor (not tenant2, even though it has higher version)
AssertDescriptor(result, CurrentTenant, 1);
}
}