Merge remote-tracking branch 'origin/release/3.6.0'
This commit is contained in:
commit
cc2e331da4
3
.github/workflows/copilot-setup-steps.yml
vendored
3
.github/workflows/copilot-setup-steps.yml
vendored
|
|
@ -17,6 +17,3 @@ jobs:
|
|||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Build, test, and pack
|
||||
run: ./build.cmd Compile Test Pack
|
||||
|
|
|
|||
10
.github/workflows/packages.yml
vendored
10
.github/workflows/packages.yml
vendored
|
|
@ -137,10 +137,10 @@ jobs:
|
|||
- name: Verify commit exists in branch
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then
|
||||
git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/*
|
||||
git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/*
|
||||
git branch --remote --contains | grep -E 'origin/(main|release/)'
|
||||
else
|
||||
git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/*
|
||||
git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/*
|
||||
git branch --remote --contains | grep origin/${BRANCH_NAME}
|
||||
fi
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ jobs:
|
|||
name: Publish to feedz.io
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
if: ${{ github.event_name == 'release' || github.event_name == 'push'}}
|
||||
steps:
|
||||
- name: Download Packages
|
||||
|
|
@ -187,7 +187,7 @@ jobs:
|
|||
name: Publish release to nuget.org
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
if: ${{ github.event.action == 'published' }}
|
||||
steps:
|
||||
- name: Download Packages
|
||||
|
|
@ -202,7 +202,7 @@ jobs:
|
|||
name: Deploy coverage to GitHub Pages
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0'
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0' || github.ref == 'refs/heads/release/3.6.0'
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/pr.yml
vendored
5
.github/workflows/pr.yml
vendored
|
|
@ -22,7 +22,6 @@ on:
|
|||
- main
|
||||
- 'patch/*'
|
||||
- 'develop/*'
|
||||
- 'release/*'
|
||||
paths:
|
||||
- '**/*'
|
||||
|
||||
|
|
@ -38,7 +37,7 @@ jobs:
|
|||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: |
|
||||
10.x
|
||||
- uses: actions/checkout@v4
|
||||
9.x
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Run: Compile, Test'
|
||||
run: ./build.cmd Compile Test
|
||||
|
|
|
|||
17
Elsa.sln
17
Elsa.sln
|
|
@ -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
|
||||
|
|
@ -196,12 +198,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
|
|||
doc\adr\0001-record-architecture-decisions.md = doc\adr\0001-record-architecture-decisions.md
|
||||
doc\adr\0002-fault-propagation-from-child-to-parent-activities.md = doc\adr\0002-fault-propagation-from-child-to-parent-activities.md
|
||||
doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md = doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md
|
||||
doc\adr\0004-token-centric-flowchart-execution-model.md = doc\adr\0004-token-centric-flowchart-execution-model.md
|
||||
doc\adr\graph.dot = doc\adr\graph.dot
|
||||
doc\adr\toc.md = doc\adr\toc.md
|
||||
doc\adr\0005-activity-execution-snapshots.md = doc\adr\0004-activity-execution-snapshots.md
|
||||
doc\adr\0006-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
|
||||
doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
|
||||
doc\adr\0005-token-centric-flowchart-execution-model.md = doc\adr\0005-token-centric-flowchart-execution-model.md
|
||||
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}"
|
||||
|
|
@ -329,6 +333,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.UnitTests", "te
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http.IntegrationTests", "test\integration\Elsa.Http.IntegrationTests\Elsa.Http.IntegrationTests.csproj", "{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Tenants.UnitTests", "test\unit\Elsa.Tenants.UnitTests\Elsa.Tenants.UnitTests.csproj", "{DC476900-D836-4920-A696-CF8796668723}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -597,6 +603,10 @@ Global
|
|||
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -701,6 +711,7 @@ Global
|
|||
{B8006D70-1630-43DB-A043-FA89FAC70F37} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
|
||||
{DC476900-D836-4920-A696-CF8796668723} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 4. Token-Centric Flowchart Execution Model
|
||||
# 5. Token-Centric Flowchart Execution Model
|
||||
|
||||
Date: 2025-05-06
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# 5. Tenant Deleted Event
|
||||
# 6. Tenant Deleted Event
|
||||
|
||||
Date: 2025-08-05
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# 6. Adoption of Explicit Merge Modes for Flowchart Joins
|
||||
# 7. Adoption of Explicit Merge Modes for Flowchart Joins
|
||||
|
||||
Date: 2025-09-30
|
||||
|
||||
48
doc/adr/0008-empty-string-as-default-tenant-id.md
Normal file
48
doc/adr/0008-empty-string-as-default-tenant-id.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 8. Empty String as Default Tenant ID
|
||||
|
||||
Date: 2026-01-27
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The multitenancy system in Elsa supports an optional mode where, when multitenancy is disabled, the system assumes a single tenant. When enabled, there's still a default tenant involved. The convention has been to use `null` as the tenant ID for the default tenant.
|
||||
|
||||
However, this convention created several issues:
|
||||
|
||||
1. **Dictionary compatibility**: The `DefaultTenantResolverPipelineInvoker` attempts to build a dictionary of tenants by their ID using `ToDictionary(x => x.Id)`, which throws an exception because dictionaries do not support null keys.
|
||||
2. **Inconsistency**: The codebase used `null`, empty string (`""`), and string literal `"default"` interchangeably to refer to the default tenant across different parts of the system (e.g., in configuration files and database records).
|
||||
3. **Code clarity**: Using `null` as a sentinel value for "default" is implicit and can be unclear to developers reading the code.
|
||||
|
||||
## Decision
|
||||
|
||||
We will standardize on using an **empty string** (`""`) as the tenant ID for the default tenant instead of `null`. This decision includes:
|
||||
|
||||
1. **Define a constant**: Add `Tenant.DefaultTenantId = ""` to explicitly document the convention.
|
||||
2. **Update Tenant.Default**: Change `Tenant.Default.Id` from `null!` to use the `DefaultTenantId` constant.
|
||||
3. **Add normalization helper**: Create a `NormalizeTenantId()` extension method that converts `null` to empty string, ensuring backwards compatibility with code that still uses null.
|
||||
4. **Apply normalization consistently**: Use the normalization method in:
|
||||
- Dictionary creation in `DefaultTenantResolverPipelineInvoker`
|
||||
- Tenant lookups in `TenantResolverContext`
|
||||
- Any other places where tenant IDs are compared or used as dictionary keys
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **No more exceptions**: Empty string is a valid dictionary key, eliminating the runtime exception in `DefaultTenantResolverPipelineInvoker`.
|
||||
- **Backwards compatible**: The `NormalizeTenantId()` extension method ensures that existing code using `null` or empty string will work correctly.
|
||||
- **Explicit convention**: The `DefaultTenantId` constant makes the convention clear and self-documenting.
|
||||
- **Simplified logic**: Reduces the need for null-checking throughout the multitenancy code.
|
||||
- **Consistency**: Aligns with parts of the codebase that were already using empty string (e.g., in configuration files).
|
||||
|
||||
### Negative
|
||||
|
||||
- **Migration consideration**: Existing data stores that have `null` tenant IDs will need to be normalized to empty strings, though the normalization helper provides a runtime solution.
|
||||
- **String vs null semantics**: Some developers may find using empty string less intuitive than null for representing "no tenant", though this is mitigated by the explicit constant.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The empty string convention is common in multitenancy systems and aligns with string-based identifier patterns used elsewhere in the codebase.
|
||||
|
|
@ -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
|
||||
|
|
@ -1,14 +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. Tenant Deleted Event"; URL="0005-tenant-deleted-event.html"];
|
||||
_4 -> _5 [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];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,4 +4,8 @@
|
|||
* [2. Fault Propagation from Child to Parent Activities](0002-fault-propagation-from-child-to-parent-activities.md)
|
||||
* [3. Direct Bookmark Management in WorkflowExecutionContext](0003-direct-bookmark-management-in-workflowexecutioncontext.md)
|
||||
* [4. Activity Execution Snapshots](0004-activity-execution-snapshots.md)
|
||||
* [5. Tenant Deleted Event](0005-tenant-deleted-event.md)
|
||||
* [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)
|
||||
* [9. Asterisk Sentinel Value for Tenant-Agnostic Entities](0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md)
|
||||
|
|
@ -4,12 +4,14 @@ using Elsa.Common.RecurringTasks;
|
|||
using Elsa.Expressions.Helpers;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Features.Services;
|
||||
using Elsa.Identity.Multitenancy;
|
||||
using Elsa.Persistence.EFCore.Extensions;
|
||||
using Elsa.Persistence.EFCore.Modules.Management;
|
||||
using Elsa.Persistence.EFCore.Modules.Runtime;
|
||||
using Elsa.Server.Web.Activities;
|
||||
using Elsa.Server.Web.ActivityHosts;
|
||||
using Elsa.Server.Web.Filters;
|
||||
using Elsa.Tenants;
|
||||
using Elsa.Tenants.AspNetCore;
|
||||
using Elsa.Tenants.Extensions;
|
||||
using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions;
|
||||
|
|
@ -29,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;
|
||||
|
|
@ -118,6 +120,17 @@ services
|
|||
http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options);
|
||||
http.UseCache();
|
||||
});
|
||||
|
||||
if(useMultitenancy)
|
||||
{
|
||||
elsa.UseTenants(tenants =>
|
||||
{
|
||||
tenants.UseConfigurationBasedTenantsProvider(options => configuration.GetSection("Multitenancy").Bind(options));
|
||||
tenants.ConfigureMultitenancy(options => options.TenantResolverPipelineBuilder = new TenantResolverPipelineBuilder()
|
||||
.Append<CurrentUserTenantResolver>());
|
||||
});
|
||||
}
|
||||
|
||||
ConfigureForTest?.Invoke(elsa);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,19 @@
|
|||
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Id": "tenant-2",
|
||||
"Name": "Tenant 2",
|
||||
"Configuration": {
|
||||
"Http": {
|
||||
"Prefix": "tenant-2",
|
||||
"Host": "localhost:5001"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
|
@ -29,13 +29,13 @@ public abstract class TenantResolverBase : ITenantResolver
|
|||
/// <summary>
|
||||
/// Creates a new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.
|
||||
/// </summary>
|
||||
protected TenantResolverResult Resolved(string tenantId) => new(tenantId);
|
||||
|
||||
protected TenantResolverResult Resolved(string? tenantId) => TenantResolverResult.Resolved(tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.
|
||||
/// </summary>
|
||||
protected TenantResolverResult Unresolved() => new(null);
|
||||
|
||||
protected TenantResolverResult Unresolved() => TenantResolverResult.Unresolved();
|
||||
|
||||
/// <summary>
|
||||
/// Automatically resolves the tenant if the tenant ID is not null.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -30,9 +30,10 @@ public class TenantResolverContext
|
|||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <returns>The found tenant or null if no tenant with the provided ID exists.</returns>
|
||||
public Tenant? FindTenant(string tenantId)
|
||||
public Tenant? FindTenant(string? tenantId)
|
||||
{
|
||||
return _tenantsDictionary.TryGetValue(tenantId, out var tenant) ? tenant : null;
|
||||
var normalizedId = tenantId.NormalizeTenantId();
|
||||
return _tenantsDictionary.TryGetValue(normalizedId, out var tenant) ? tenant : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ namespace Elsa.Common.Multitenancy;
|
|||
|
||||
public interface ITenantAccessor
|
||||
{
|
||||
string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Get the current <see cref="Multitenancy.Tenant"/>.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
namespace Elsa.Common.Multitenancy;
|
||||
|
||||
/// <summary>
|
||||
/// A strategy for resolving the current tenant. This is called the tenant initializer.
|
||||
/// A strategy for resolving the current tenant, called from the tenant initializer.
|
||||
/// </summary>
|
||||
public interface ITenantResolver
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ namespace Elsa.Common.Multitenancy;
|
|||
[UsedImplicitly]
|
||||
public class Tenant : Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID used for the default tenant.
|
||||
/// </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>
|
||||
|
|
@ -19,10 +29,10 @@ public class Tenant : Entity
|
|||
/// Gets or sets the configuration.
|
||||
/// </summary>
|
||||
public IConfiguration Configuration { get; set; } = new ConfigurationBuilder().Build();
|
||||
|
||||
|
||||
public static readonly Tenant Default = new()
|
||||
{
|
||||
Id = null!,
|
||||
Id = DefaultTenantId,
|
||||
Name = "Default"
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ namespace Elsa.Common.Multitenancy;
|
|||
[UsedImplicitly]
|
||||
public static class TenantsProviderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes a tenant ID by converting null to empty string, ensuring consistency with the default tenant convention.
|
||||
/// </summary>
|
||||
public static string NormalizeTenantId(this string? tenantId) => tenantId ?? Tenant.DefaultTenantId;
|
||||
|
||||
public static async Task<Tenant?> FindByIdAsync(this ITenantsProvider tenantsProvider, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = new TenantFilter
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,26 +3,38 @@ namespace Elsa.Common.Multitenancy;
|
|||
/// <summary>
|
||||
/// Represents the result of a tenant resolution.
|
||||
/// </summary>
|
||||
/// <param name="TenantId">The resolved tenant.</param>
|
||||
public record TenantResolverResult(string? TenantId)
|
||||
public record TenantResolverResult
|
||||
{
|
||||
private readonly bool _isResolved;
|
||||
|
||||
private TenantResolverResult(string? tenantId, bool isResolved)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
_isResolved = isResolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The normalized tenant ID. Returns null if unresolved.
|
||||
/// </summary>
|
||||
public string? TenantId => _isResolved ? field.NormalizeTenantId() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The resolved tenant.</param>
|
||||
/// <returns>A new instance of <see cref="TenantResolverResult"/> representing a resolved tenant.</returns>
|
||||
public static TenantResolverResult Resolved(string tenantId) => new(tenantId);
|
||||
|
||||
public static TenantResolverResult Resolved(string? tenantId) => new(tenantId, true);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.
|
||||
/// </summary>
|
||||
/// <returns>A new instance of <see cref="TenantResolverResult"/> representing an unresolved tenant.</returns>
|
||||
public static TenantResolverResult Unresolved() => new(default(string?));
|
||||
|
||||
public static TenantResolverResult Unresolved() => new(null, false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the tenant has been resolved.
|
||||
/// </summary>
|
||||
public bool IsResolved => TenantId != null;
|
||||
|
||||
public bool IsResolved => _isResolved;
|
||||
|
||||
public string ResolveTenantId() => TenantId ?? throw new InvalidOperationException("Tenant has not been resolved.");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.Labels.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="Delete"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "DeleteLabel";
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Elsa.Labels.Endpoints.Labels.Delete;
|
||||
|
||||
public class Delete : Endpoint<Request>
|
||||
public class Delete : ElsaEndpoint<Request>
|
||||
{
|
||||
private readonly ILabelStore _store;
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ public class Delete : Endpoint<Request>
|
|||
public override void Configure()
|
||||
{
|
||||
Delete("/labels/{id}");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("delete:labels");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.Labels.Get;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="Get"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "GetLabel";
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Elsa.Labels.Endpoints.Labels.Get;
|
||||
|
||||
internal class Get : Endpoint<Request, Response, LabelMapper>
|
||||
internal class Get : ElsaEndpoint<Request, Response, LabelMapper>
|
||||
{
|
||||
private readonly ILabelStore _store;
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ internal class Get : Endpoint<Request, Response, LabelMapper>
|
|||
public override void Configure()
|
||||
{
|
||||
Get("/labels/{id}");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("read:labels");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.Labels.List;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="List"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "ListLabels";
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Elsa.Labels.Endpoints.Labels.List;
|
||||
|
||||
public class List : Endpoint<Request, Response, PageMapper>
|
||||
public class List : ElsaEndpoint<Request, Response, PageMapper>
|
||||
{
|
||||
private readonly ILabelStore _store;
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ public class List : Endpoint<Request, Response, PageMapper>
|
|||
public override void Configure()
|
||||
{
|
||||
Get("/labels");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("read:labels");
|
||||
}
|
||||
|
||||
public override async Task<Response> ExecuteAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.Labels.Post;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="Post"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "CreateLabel";
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using Elsa.Workflows;
|
||||
using FastEndpoints;
|
||||
|
|
@ -6,7 +7,7 @@ using JetBrains.Annotations;
|
|||
namespace Elsa.Labels.Endpoints.Labels.Post;
|
||||
|
||||
[PublicAPI]
|
||||
internal class Create : Endpoint<Request, Response, LabelMapper>
|
||||
internal class Create : ElsaEndpoint<Request, Response, LabelMapper>
|
||||
{
|
||||
private readonly ILabelStore _store;
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ internal class Create : Endpoint<Request, Response, LabelMapper>
|
|||
public override void Configure()
|
||||
{
|
||||
Post("/labels");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("create:labels");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.Labels.Update;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="Update"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "UpdateLabel";
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using FastEndpoints;
|
||||
using JetBrains.Annotations;
|
||||
|
|
@ -5,12 +6,12 @@ using JetBrains.Annotations;
|
|||
namespace Elsa.Labels.Endpoints.Labels.Update;
|
||||
|
||||
[PublicAPI]
|
||||
internal class Update(ILabelStore store) : Endpoint<Request, Response, LabelMapper>
|
||||
internal class Update(ILabelStore store) : ElsaEndpoint<Request, Response, LabelMapper>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/labels/{id}");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("update:labels");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.WorkflowDefinitionLabels.List;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="List"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "ListWorkflowDefinitionLabels";
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using FastEndpoints;
|
||||
using JetBrains.Annotations;
|
||||
using Open.Linq.AsyncExtensions;
|
||||
|
||||
namespace Elsa.Labels.Endpoints.WorkflowDefinitionLabels.List;
|
||||
|
||||
[PublicAPI]
|
||||
internal class List : Endpoint<Request, Response>
|
||||
internal class List : ElsaEndpoint<Request, Response>
|
||||
{
|
||||
private readonly IWorkflowDefinitionStore _workflowDefinitionStore;
|
||||
private readonly IWorkflowDefinitionLabelStore _workflowDefinitionLabelStore;
|
||||
|
|
@ -24,7 +24,7 @@ internal class List : Endpoint<Request, Response>
|
|||
public override void Configure()
|
||||
{
|
||||
Get("/workflow-definitions/{id}/labels");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("read:workflow-definition-labels");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Elsa.Labels.Endpoints.WorkflowDefinitionLabels.Update;
|
||||
|
||||
/// <summary>
|
||||
/// Provides policy names accepted by the <see cref="Update"/> endpoint.
|
||||
/// </summary>
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The policy name accepted by this endpoint.
|
||||
/// </summary>
|
||||
public const string PolicyName = "UpdateWorkflowDefinitionLabels";
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Labels.Contracts;
|
||||
using Elsa.Labels.Entities;
|
||||
using Elsa.Workflows;
|
||||
|
|
@ -11,7 +12,7 @@ using Open.Linq.AsyncExtensions;
|
|||
namespace Elsa.Labels.Endpoints.WorkflowDefinitionLabels.Update;
|
||||
|
||||
[PublicAPI]
|
||||
internal class Update : Endpoint<Request, Response>
|
||||
internal class Update : ElsaEndpoint<Request, Response>
|
||||
{
|
||||
private readonly ILabelStore _labelStore;
|
||||
private readonly IWorkflowDefinitionStore _workflowDefinitionStore;
|
||||
|
|
@ -42,7 +43,7 @@ internal class Update : Endpoint<Request, Response>
|
|||
public override void Configure()
|
||||
{
|
||||
Post("/workflow-definitions/{id}/labels");
|
||||
Policies(Constants.PolicyName);
|
||||
ConfigurePermissions("update:workflow-definition-labels");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
using Elsa.Persistence.EFCore.EntityHandlers;
|
||||
using Elsa.Features.Abstractions;
|
||||
using Elsa.Features.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elsa.Persistence.EFCore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class CommonPersistenceFeature(IModule module) : FeatureBase(module)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Apply()
|
||||
{
|
||||
Services.AddScoped<IEntitySavingHandler, ApplyTenantId>();
|
||||
Services.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>();
|
||||
}
|
||||
}
|
||||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using Elsa.Common.Entities;
|
|||
using Elsa.Extensions;
|
||||
using Elsa.Features.Abstractions;
|
||||
using Elsa.Features.Services;
|
||||
using Elsa.Persistence.EFCore.EntityHandlers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -60,6 +61,9 @@ public abstract class PersistenceFeatureBase<TFeature, TDbContext>(IModule modul
|
|||
{
|
||||
options.RunMigrations[typeof(TDbContext)] = RunMigrations;
|
||||
});
|
||||
|
||||
Services.AddScoped<IEntitySavingHandler, ApplyTenantId>();
|
||||
Services.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>();
|
||||
}
|
||||
|
||||
protected virtual void ConfigureMigrations()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,10 +30,10 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Runtime
|
|||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers",
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Runtime
|
|||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers");
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
|
|||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers",
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
|
||||
unique: true,
|
||||
filter: "\"Hash\" IS NOT NULL");
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Runtime
|
|||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Runtime
|
|||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers",
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Runtime
|
|||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,9 +317,9 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
|
|||
b.HasIndex("WorkflowDefinitionVersionId")
|
||||
.HasDatabaseName("IX_StoredTrigger_WorkflowDefinitionVersionId");
|
||||
|
||||
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId")
|
||||
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId", "TenantId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId")
|
||||
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId")
|
||||
.HasFilter("[Hash] IS NOT NULL");
|
||||
|
||||
b.ToTable("Triggers", "Elsa");
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
|
|||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Drop old index if it exists (before TenantId was added)
|
||||
migrationBuilder.Sql($@"
|
||||
IF EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId'
|
||||
AND object_id = OBJECT_ID('{_schema.Schema}.Triggers'))
|
||||
BEGIN
|
||||
DROP INDEX [IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId]
|
||||
ON [{_schema.Schema}].[Triggers]
|
||||
END
|
||||
");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ActivityId",
|
||||
schema: _schema.Schema,
|
||||
|
|
@ -28,10 +38,10 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
|
|||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers",
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
|
||||
unique: true,
|
||||
filter: "[Hash] IS NOT NULL");
|
||||
}
|
||||
|
|
@ -40,7 +50,7 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
|
|||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers");
|
||||
|
||||
|
|
|
|||
|
|
@ -314,9 +314,9 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Runtime
|
|||
b.HasIndex("WorkflowDefinitionVersionId")
|
||||
.HasDatabaseName("IX_StoredTrigger_WorkflowDefinitionVersionId");
|
||||
|
||||
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId")
|
||||
b.HasIndex("WorkflowDefinitionId", "Hash", "ActivityId", "TenantId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId")
|
||||
.HasDatabaseName("IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId")
|
||||
.HasFilter("[Hash] IS NOT NULL");
|
||||
|
||||
b.ToTable("Triggers", "Elsa");
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime
|
|||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers",
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId" },
|
||||
columns: new[] { "WorkflowDefinitionId", "Hash", "ActivityId", "TenantId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Runtime
|
|||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId",
|
||||
name: "IX_StoredTrigger_Unique_WorkflowDefinitionId_Hash_ActivityId_TenantId",
|
||||
schema: _schema.Schema,
|
||||
table: "Triggers");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,15 +125,16 @@ public class Configurations :
|
|||
builder.HasIndex(x => x.TenantId).HasDatabaseName($"IX_{nameof(StoredTrigger)}_{nameof(StoredTrigger.TenantId)}");
|
||||
|
||||
// Add unique constraint to prevent duplicate trigger registrations in multi-engine environments
|
||||
// A trigger is uniquely identified by WorkflowDefinitionId + Hash + ActivityId
|
||||
// A trigger is uniquely identified by WorkflowDefinitionId + Hash + ActivityId + TenantId
|
||||
builder.HasIndex(x => new
|
||||
{
|
||||
x.WorkflowDefinitionId,
|
||||
x.Hash,
|
||||
x.ActivityId
|
||||
x.ActivityId,
|
||||
x.TenantId
|
||||
})
|
||||
.IsUnique()
|
||||
.HasDatabaseName($"IX_{nameof(StoredTrigger)}_Unique_{nameof(StoredTrigger.WorkflowDefinitionId)}_{nameof(StoredTrigger.Hash)}_{nameof(StoredTrigger.ActivityId)}");
|
||||
.HasDatabaseName($"IX_{nameof(StoredTrigger)}_Unique_{nameof(StoredTrigger.WorkflowDefinitionId)}_{nameof(StoredTrigger.Hash)}_{nameof(StoredTrigger.ActivityId)}_{nameof(StoredTrigger.TenantId)}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Elsa.Features.Services;
|
||||
using Elsa.Tenants.Features;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
// ReSharper disable once CheckNamespace
|
||||
namespace Elsa.Tenants.Extensions;
|
||||
|
|
@ -7,32 +8,39 @@ namespace Elsa.Tenants.Extensions;
|
|||
/// <summary>
|
||||
/// Extensions for <see cref="IModule"/> that installs the <see cref="TenantsFeature"/> feature.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public static class ModuleExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Installs and configures the <see cref="TenantsFeature"/> feature.
|
||||
/// </summary>
|
||||
public static IModule UseTenants(this IModule module, Action<TenantsFeature>? configure = default)
|
||||
[UsedImplicitly]
|
||||
public static IModule UseTenants(this IModule module, Action<TenantsFeature>? configure = null)
|
||||
{
|
||||
module.Configure(configure);
|
||||
return module;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs and configures the <see cref="TenantManagementEndpointsFeature"/> feature.
|
||||
/// </summary>
|
||||
public static TenantsFeature UseTenantManagementEndpoints(this TenantsFeature feature, Action<TenantManagementEndpointsFeature>? configure = default)
|
||||
extension(TenantsFeature feature)
|
||||
{
|
||||
feature.Module.Configure(configure);
|
||||
return feature;
|
||||
}
|
||||
/// <summary>
|
||||
/// Installs and configures the <see cref="TenantManagementEndpointsFeature"/> feature.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public TenantsFeature UseTenantManagementEndpoints(Action<TenantManagementEndpointsFeature>? configure = null)
|
||||
{
|
||||
feature.Module.Configure(configure);
|
||||
return feature;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs and configures the <see cref="TenantManagementFeature"/> feature.
|
||||
/// </summary>
|
||||
public static TenantsFeature UseTenantManagement(this TenantsFeature feature, Action<TenantManagementFeature>? configure = default)
|
||||
{
|
||||
feature.Module.Configure(configure);
|
||||
return feature;
|
||||
/// <summary>
|
||||
/// Installs and configures the <see cref="TenantManagementFeature"/> feature.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public TenantsFeature UseTenantManagement(Action<TenantManagementFeature>? configure = null)
|
||||
{
|
||||
feature.Module.Configure(configure);
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,8 @@ public class DefaultTenantResolverPipelineInvoker(
|
|||
public async Task<Tenant?> InvokePipelineAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resolutionPipeline = options.Value.TenantResolverPipelineBuilder.Build(serviceProvider);
|
||||
var tenantsDictionary = (await tenantsProvider.ListAsync(cancellationToken)).ToDictionary(x => x.Id);
|
||||
var tenants = await tenantsProvider.ListAsync(cancellationToken);
|
||||
var tenantsDictionary = tenants.ToDictionary(x => x.Id.NormalizeTenantId());
|
||||
var context = new TenantResolverContext(tenantsDictionary, cancellationToken);
|
||||
|
||||
foreach (var resolver in resolutionPipeline)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
43
src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs
Normal file
43
src/modules/Elsa.Workflows.Core/Models/TenantRegistryData.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -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,75 +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>();
|
||||
|
||||
// First, preserve manually registered descriptors from Register/RegisterAsync(Type) calls, which are stored under the ActivityRegistry type (GetType()) as the provider key, without logging warnings (since we're starting fresh).
|
||||
if (_providedActivityDescriptors.TryGetValue(GetType(), out var manualDescriptors))
|
||||
{
|
||||
var preservedManualDescriptors = new List<ActivityDescriptor>();
|
||||
providersDictionary[GetType()] = preservedManualDescriptors;
|
||||
|
||||
foreach (var manualDescriptor in manualDescriptors)
|
||||
{
|
||||
activityDescriptors[(manualDescriptor.TypeName, manualDescriptor.Version)] = manualDescriptor;
|
||||
preservedManualDescriptors.Add(manualDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
// Also add descriptors from _manualActivityDescriptors (from RegisterAsync(Type activityType) calls).
|
||||
// These should also be tracked under the GetType() provider key to keep providersDictionary consistent.
|
||||
if (_manualActivityDescriptors.Count > 0)
|
||||
{
|
||||
if (!providersDictionary.TryGetValue(GetType(), out var manualProviderDescriptors))
|
||||
{
|
||||
manualProviderDescriptors = new List<ActivityDescriptor>();
|
||||
providersDictionary[GetType()] = manualProviderDescriptors;
|
||||
}
|
||||
|
||||
foreach (var manualDescriptor in _manualActivityDescriptors)
|
||||
{
|
||||
activityDescriptors[(manualDescriptor.TypeName, manualDescriptor.Version)] = manualDescriptor;
|
||||
|
||||
// Avoid adding duplicates to the provider list if the descriptor was already preserved.
|
||||
if (!manualProviderDescriptors.Contains(manualDescriptor))
|
||||
manualProviderDescriptors.Add(manualDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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)
|
||||
|
|
@ -163,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;
|
||||
|
|
@ -173,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ public class WorkflowDefinitionActivityDescriptorFactory
|
|||
public ActivityDescriptor CreateDescriptor(WorkflowDefinition definition, WorkflowDefinition? latestPublishedDefinition = null)
|
||||
{
|
||||
var typeName = definition.Name!.Pascalize();
|
||||
var tenantId = definition.TenantId;
|
||||
|
||||
var ports = definition.Outcomes.Select(outcome => new Port
|
||||
{
|
||||
|
|
@ -31,6 +32,7 @@ public class WorkflowDefinitionActivityDescriptorFactory
|
|||
|
||||
return new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TypeName = typeName,
|
||||
ClrType = typeof(WorkflowDefinitionActivity),
|
||||
Name = typeName,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Models;
|
||||
|
|
@ -6,9 +7,9 @@ using Elsa.Workflows.Models;
|
|||
namespace Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity;
|
||||
|
||||
/// <summary>
|
||||
/// Provides activity descriptors based on <see cref="WorkflowDefinition"/>s stored in the database.
|
||||
/// Provides activity descriptors based on <see cref="WorkflowDefinition"/>s stored in the database.
|
||||
/// </summary>
|
||||
public class WorkflowDefinitionActivityProvider(IWorkflowDefinitionStore store, WorkflowDefinitionActivityDescriptorFactory workflowDefinitionActivityDescriptorFactory) : IActivityProvider
|
||||
public class WorkflowDefinitionActivityProvider(IWorkflowDefinitionStore store, WorkflowDefinitionActivityDescriptorFactory workflowDefinitionActivityDescriptorFactory, ITenantAccessor tenantAccessor) : IActivityProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
|
||||
|
|
@ -18,7 +19,7 @@ public class WorkflowDefinitionActivityProvider(IWorkflowDefinitionStore store,
|
|||
UsableAsActivity = true,
|
||||
VersionOptions = VersionOptions.All
|
||||
};
|
||||
|
||||
|
||||
var definitions = (await store.FindManyAsync(filter, cancellationToken)).ToList();
|
||||
return CreateDescriptors(definitions).ToList();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.FindOrCreateAsync(internalKey, async entry =>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ internal class ResumeBulkDispatchWorkflowActivity(IBookmarkQueue bookmarkQueue,
|
|||
if (!waitForCompletion)
|
||||
return;
|
||||
|
||||
var parentInstanceId = (string)workflowState.Properties["ParentInstanceId"];
|
||||
if (!workflowState.Properties.TryGetValue("ParentInstanceId", out var parentInstanceIdValue))
|
||||
return;
|
||||
|
||||
var parentInstanceId = (string)parentInstanceIdValue;
|
||||
var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<BulkDispatchWorkflows>();
|
||||
var stimulus = new BulkDispatchWorkflowsStimulus(parentInstanceId);
|
||||
var stimulusHash = stimulusHasher.Hash(activityTypeName, stimulus);
|
||||
|
|
|
|||
|
|
@ -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?.NullIfEmpty()
|
||||
TenantId = workflow.Identity.TenantId
|
||||
};
|
||||
|
||||
var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType());
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
|
|
@ -19,6 +20,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
|
|||
private readonly IPayloadSerializer _payloadSerializer;
|
||||
private readonly ISystemClock _systemClock;
|
||||
private readonly IIdentityGraphService _identityGraphService;
|
||||
private readonly ITenantAccessor _tenantAccessor;
|
||||
private readonly ILogger<DefaultWorkflowDefinitionStorePopulator> _logger;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
|
|||
IPayloadSerializer payloadSerializer,
|
||||
ISystemClock systemClock,
|
||||
IIdentityGraphService identityGraphService,
|
||||
ITenantAccessor tenantAccessor,
|
||||
ILogger<DefaultWorkflowDefinitionStorePopulator> logger)
|
||||
{
|
||||
_workflowDefinitionProviders = workflowDefinitionProviders;
|
||||
|
|
@ -42,6 +45,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
|
|||
_payloadSerializer = payloadSerializer;
|
||||
_systemClock = systemClock;
|
||||
_identityGraphService = identityGraphService;
|
||||
_tenantAccessor = tenantAccessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +60,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
|
|||
{
|
||||
var providers = _workflowDefinitionProviders();
|
||||
var workflowDefinitions = new List<WorkflowDefinition>();
|
||||
var currentTenantId = (_tenantAccessor.Tenant?.Id).NormalizeTenantId();
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
|
|
@ -63,6 +68,21 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
|
|||
|
||||
foreach (var result in results)
|
||||
{
|
||||
// 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}'",
|
||||
result.Workflow.Identity.DefinitionId,
|
||||
provider.Name,
|
||||
result.Workflow.Identity.TenantId,
|
||||
currentTenantId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var workflowDefinition = await AddAsync(result, indexTriggers, cancellationToken);
|
||||
workflowDefinitions.Add(workflowDefinition);
|
||||
}
|
||||
|
|
@ -128,7 +148,9 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
|
|||
{
|
||||
// NEW WAY: OriginalSource is provided
|
||||
// For JSON workflows, we still need to populate StringData with the serialized root for backwards compatibility
|
||||
stringData = materializedWorkflow.MaterializerName == "Json" ? _activitySerializer.Serialize(workflow.Root) :
|
||||
stringData = materializedWorkflow.MaterializerName == "Json"
|
||||
? _activitySerializer.Serialize(workflow.Root)
|
||||
:
|
||||
// For new formats (ElsaScript, YAML, etc.), only OriginalSource is needed
|
||||
// StringData can be null as these materializers only use OriginalSource
|
||||
null;
|
||||
|
|
@ -168,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;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<Threshold>36</Threshold>
|
||||
<Threshold>25</Threshold>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,39 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows.ComponentTests.Fixtures;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Models;
|
||||
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()
|
||||
{
|
||||
// Wait for all workflows to reach terminal state before disposing scope
|
||||
// This prevents TaskCanceledException when workflows are still executing
|
||||
WaitForWorkflowsToComplete();
|
||||
|
||||
_tenantScope.Dispose();
|
||||
Scope.Dispose();
|
||||
OnDispose();
|
||||
}
|
||||
|
|
@ -20,4 +41,40 @@ public abstract class AppComponentTest(App app) : IDisposable
|
|||
protected virtual void OnDispose()
|
||||
{
|
||||
}
|
||||
|
||||
private void WaitForWorkflowsToComplete()
|
||||
{
|
||||
try
|
||||
{
|
||||
var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService<IWorkflowInstanceStore>();
|
||||
var timeout = TimeSpan.FromSeconds(10);
|
||||
var pollInterval = TimeSpan.FromMilliseconds(50);
|
||||
var deadline = DateTime.UtcNow.Add(timeout);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var filter = new WorkflowInstanceFilter
|
||||
{
|
||||
WorkflowStatus = WorkflowStatus.Running
|
||||
};
|
||||
|
||||
// Use async method synchronously - acceptable in cleanup/dispose
|
||||
var runningWorkflows = workflowInstanceStore.FindManyAsync(filter, CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
if (!runningWorkflows.Any())
|
||||
return; // All workflows completed
|
||||
|
||||
Thread.Sleep(pollInterval);
|
||||
}
|
||||
|
||||
// If we reach here, workflows didn't complete in time
|
||||
// Log but don't throw to avoid masking actual test failures
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow exceptions during cleanup to avoid masking test failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -105,6 +109,8 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
|
|||
});
|
||||
runtime.UseCache();
|
||||
runtime.UseDistributedRuntime();
|
||||
// Use test-specific bookmark queue worker without throttling to prevent timeouts
|
||||
runtime.BookmarkQueueWorker = sp => sp.GetRequiredService<TestBookmarkQueueWorker>();
|
||||
});
|
||||
elsa.UseJavaScript(options =>
|
||||
{
|
||||
|
|
@ -126,21 +132,31 @@ 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>());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// Decorate IDistributedLockProvider with TestDistributedLockProvider so tests use it
|
||||
services.Decorate<IDistributedLockProvider, TestDistributedLockProvider>();
|
||||
|
||||
// Also register TestDistributedLockProvider as itself so tests can access it directly for configuration
|
||||
// Decorate IDistributedLockProvider with SelectiveMockLockProvider
|
||||
// This allows tests to selectively mock specific locks without affecting background operations
|
||||
services.Decorate<IDistributedLockProvider, SelectiveMockLockProvider>();
|
||||
|
||||
// Register SelectiveMockLockProvider as itself so tests can access it for configuration
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var provider = sp.GetRequiredService<IDistributedLockProvider>();
|
||||
if (provider is not TestDistributedLockProvider testProvider)
|
||||
throw new InvalidOperationException($"Expected IDistributedLockProvider to be decorated with TestDistributedLockProvider, but got {provider.GetType().Name}");
|
||||
return testProvider;
|
||||
if (provider is not SelectiveMockLockProvider selectiveProvider)
|
||||
throw new InvalidOperationException($"Expected IDistributedLockProvider to be decorated with SelectiveMockLockProvider, but got {provider.GetType().Name}");
|
||||
return selectiveProvider;
|
||||
});
|
||||
|
||||
services
|
||||
|
|
@ -154,7 +170,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
|
|||
.AddWorkflowsProvider<TestWorkflowProvider>()
|
||||
.AddNotificationHandlersFrom<WorkflowEventHandlers>()
|
||||
.Decorate<IChangeTokenSignaler, EventPublishingChangeTokenSignaler>()
|
||||
.Decorate<IDistributedLockProvider, TestDistributedLockProvider>()
|
||||
.AddSingleton<TestBookmarkQueueWorker>()
|
||||
;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
using Elsa.Workflows.Runtime;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Elsa.Workflows.ComponentTests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A test-specific bookmark queue worker that processes items immediately without throttling.
|
||||
/// This prevents timeouts in tests where many workflows complete rapidly.
|
||||
/// </summary>
|
||||
public class TestBookmarkQueueWorker(IBookmarkQueueSignaler signaler, IServiceScopeFactory scopeFactory, ILogger<TestBookmarkQueueWorker> logger) : IBookmarkQueueWorker
|
||||
{
|
||||
private CancellationTokenSource _cts = null!;
|
||||
private bool _running;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_running)
|
||||
return;
|
||||
|
||||
_cts = new();
|
||||
_running = true;
|
||||
|
||||
_ = Task.Run(AwaitSignalAsync);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_running)
|
||||
{
|
||||
_running = false;
|
||||
_cts.Cancel();
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task AwaitSignalAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await signaler.AwaitAsync(_cts.Token);
|
||||
// Process immediately without throttling for tests
|
||||
await ProcessAsync(_cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break; // Stop() was called
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "TestBookmarkQueueWorker error – continuing loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual async Task ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("Processing bookmark queue (test mode - no throttling)...");
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<IBookmarkQueueProcessor>();
|
||||
await processor.ProcessAsync(cancellationToken);
|
||||
logger.LogDebug("Processed bookmark queue.");
|
||||
}
|
||||
}
|
||||
|
|
@ -19,10 +19,10 @@ namespace Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience;
|
|||
public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
|
||||
{
|
||||
private const int MaxRetryAttempts = 3;
|
||||
|
||||
// The IDistributedLockProvider is decorated with TestDistributedLockProvider in WorkflowServer.ConfigureTestServices
|
||||
// This cast is safe because the decorator pattern ensures TestDistributedLockProvider wraps the actual provider
|
||||
private TestDistributedLockProvider MockProvider => (TestDistributedLockProvider)Scope.ServiceProvider.GetRequiredService<IDistributedLockProvider>();
|
||||
|
||||
// Selective mock provider - only mocks specific locks, not all locks globally
|
||||
private SelectiveMockLockProvider SelectiveMockProvider => Scope.ServiceProvider.GetRequiredService<SelectiveMockLockProvider>();
|
||||
|
||||
private ITransientExceptionDetector TransientExceptionDetector => Scope.ServiceProvider.GetRequiredService<ITransientExceptionDetector>();
|
||||
private ILogger<DistributedLockResilienceTests> Logger => Scope.ServiceProvider.GetRequiredService<ILogger<DistributedLockResilienceTests>>();
|
||||
private DistributedLockingOptions LockOptions => Scope.ServiceProvider.GetRequiredService<IOptions<DistributedLockingOptions>>().Value;
|
||||
|
|
@ -34,22 +34,25 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
|
|||
[InlineData(4, 4, true)] // Four failures, exhausts retries (MaxRetryAttempts = 3)
|
||||
public async Task AcquireLockWithRetry_AcquisitionFailures_BehavesAsExpected(int failureCount, int expectedAttemptCount, bool shouldThrow)
|
||||
{
|
||||
// Arrange
|
||||
MockProvider.Reset();
|
||||
MockProvider.FailAcquisitionTimes(failureCount);
|
||||
// Arrange - Mock this specific lock only
|
||||
var lockName = $"test-lock-{failureCount}";
|
||||
var mockProvider = SelectiveMockProvider.MockLock(lockName);
|
||||
mockProvider.Reset();
|
||||
mockProvider.FailAcquisitionTimes(failureCount);
|
||||
|
||||
// Act & Assert
|
||||
if (shouldThrow)
|
||||
{
|
||||
await Assert.ThrowsAsync<TimeoutException>(async () => await AcquireLockWithRetryAsync($"test-lock-{failureCount}"));
|
||||
await Assert.ThrowsAsync<TimeoutException>(async () => await AcquireLockWithRetryAsync(lockName, mockProvider));
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var handle = await AcquireLockWithRetryAsync($"test-lock-{failureCount}");
|
||||
await using var handle = await AcquireLockWithRetryAsync(lockName, mockProvider);
|
||||
Assert.NotNull(handle);
|
||||
}
|
||||
|
||||
Assert.Equal(expectedAttemptCount, MockProvider.AcquisitionAttemptCount);
|
||||
// Assert exact count - only this lock is mocked
|
||||
Assert.Equal(expectedAttemptCount, mockProvider.AcquisitionAttemptCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -62,10 +65,11 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
|
|||
var workflowClient = await CreateWorkflowClientAsync();
|
||||
var workflowInstanceId = workflowClient.WorkflowInstanceId;
|
||||
|
||||
// Reset and configure failures for this specific workflow instance's lock
|
||||
MockProvider.Reset();
|
||||
MockProvider.FailAcquisitionTimesForLock($"workflow-instance:{workflowInstanceId}", failureCount);
|
||||
var attemptCountBefore = MockProvider.AcquisitionAttemptCount;
|
||||
// Configure failures for this specific workflow instance's lock only
|
||||
var lockPrefix = $"workflow-instance:{workflowInstanceId}";
|
||||
var mockProvider = SelectiveMockProvider.MockLock(lockPrefix);
|
||||
mockProvider.Reset();
|
||||
mockProvider.FailAcquisitionTimes(failureCount);
|
||||
|
||||
// Now run the instance with the configured lock failures
|
||||
var runRequest = new RunWorkflowInstanceRequest();
|
||||
|
|
@ -82,34 +86,42 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
|
|||
Assert.NotNull(response);
|
||||
}
|
||||
|
||||
// Verify retries occurred - check the delta from before the operation to account for background noise
|
||||
var expectedAttempts = failureCount + 1; // failures + 1 success (or final failure for shouldThrow case)
|
||||
AssertMinimumAttempts(MockProvider.AcquisitionAttemptCount - attemptCountBefore, expectedAttempts, "acquisition");
|
||||
// Assert exact count - only this specific workflow instance lock is mocked
|
||||
// When shouldThrow=true, all attempts fail: MaxRetryAttempts+1 (initial + retries)
|
||||
// When shouldThrow=false, we succeed after failures: failureCount+1 (failures + success)
|
||||
var expectedAttempts = shouldThrow ? MaxRetryAttempts + 1 : failureCount + 1;
|
||||
Assert.Equal(expectedAttempts, mockProvider.AcquisitionAttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunInstanceAsync_TransientReleaseFailure_ShouldLogButNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
MockProvider.Reset();
|
||||
var workflowClient = await CreateWorkflowClientAsync(createInstance: false);
|
||||
|
||||
// Configure failure after client creation to minimize background interference
|
||||
MockProvider.FailReleaseOnce();
|
||||
var releaseCountBefore = MockProvider.ReleaseAttemptCount;
|
||||
// Create and run to get the workflow instance ID, then configure release failure for that lock
|
||||
var request = CreateAndRunRequest();
|
||||
|
||||
// Mock the workflow-instance lock prefix (all workflow instance locks)
|
||||
var mockProvider = SelectiveMockProvider.MockLock("workflow-instance:");
|
||||
mockProvider.Reset();
|
||||
mockProvider.FailReleaseOnce();
|
||||
|
||||
// Act - Release failure should be caught and logged, not thrown
|
||||
var response = await workflowClient.CreateAndRunInstanceAsync(CreateAndRunRequest());
|
||||
var response = await workflowClient.CreateAndRunInstanceAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotNull(response.WorkflowInstanceId);
|
||||
AssertMinimumAttempts(MockProvider.ReleaseAttemptCount - releaseCountBefore, 1, "release");
|
||||
|
||||
// Verify at least one release occurred
|
||||
Assert.True(mockProvider.ReleaseAttemptCount >= 1,
|
||||
$"Expected at least 1 release attempt, but got {mockProvider.ReleaseAttemptCount}");
|
||||
}
|
||||
|
||||
private async Task<IDistributedSynchronizationHandle?> AcquireLockWithRetryAsync(string lockName) =>
|
||||
private async Task<IDistributedSynchronizationHandle?> AcquireLockWithRetryAsync(string lockName, TestDistributedLockProvider mockProvider) =>
|
||||
await RetryPipeline.ExecuteAsync(async ct =>
|
||||
await MockProvider.AcquireLockAsync(lockName, LockOptions.LockAcquisitionTimeout, ct),
|
||||
await mockProvider.CreateLock(lockName).AcquireAsync(LockOptions.LockAcquisitionTimeout, ct),
|
||||
CancellationToken.None);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -138,10 +150,6 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
|
|||
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SimpleWorkflow.DefinitionId, VersionOptions.Latest)
|
||||
};
|
||||
|
||||
private static void AssertMinimumAttempts(int actualAttempts, int expectedAttempts, string attemptType) =>
|
||||
Assert.True(actualAttempts >= expectedAttempts,
|
||||
$"Expected at least {expectedAttempts} {attemptType} attempts, but got {actualAttempts}");
|
||||
|
||||
private static ResiliencePipeline CreateRetryPipeline(ITransientExceptionDetector transientExceptionDetector, ILogger logger) =>
|
||||
new ResiliencePipelineBuilder()
|
||||
.AddRetry(new()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
using Medallion.Threading;
|
||||
|
||||
namespace Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// A lock provider that selectively mocks specific locks while allowing others to use the real implementation.
|
||||
///
|
||||
/// WHY THIS IS NECESSARY:
|
||||
/// - Workflow operations trigger background processes (trigger indexing, state persistence, etc.)
|
||||
/// - These background operations acquire their own locks concurrently
|
||||
/// - If we mock ALL locks globally, background operations can consume configured failures
|
||||
/// - This makes test assertions unreliable and flaky
|
||||
///
|
||||
/// SOLUTION:
|
||||
/// - Only mock locks matching specific prefixes configured by tests
|
||||
/// - Background operations use real locks (not counted, not mocked)
|
||||
/// - Test operations use mocked locks (counted, failures injected)
|
||||
/// - Result: Deterministic, reliable test assertions
|
||||
/// </summary>
|
||||
public class SelectiveMockLockProvider : IDistributedLockProvider, IDisposable
|
||||
{
|
||||
private readonly IDistributedLockProvider _realProvider;
|
||||
private readonly Dictionary<string, TestDistributedLockProvider> _mockProvidersByPrefix = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SelectiveMockLockProvider(IDistributedLockProvider realProvider)
|
||||
{
|
||||
_realProvider = realProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the real/inner provider being wrapped.
|
||||
/// </summary>
|
||||
public IDistributedLockProvider RealProvider => _realProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Configures mocking for locks matching the specified prefix.
|
||||
/// Returns a test provider that allows configuring failures for these locks.
|
||||
/// </summary>
|
||||
public TestDistributedLockProvider MockLock(string lockNamePrefix)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_mockProvidersByPrefix.TryGetValue(lockNamePrefix, out var mockProvider))
|
||||
{
|
||||
mockProvider = new TestDistributedLockProvider(_realProvider);
|
||||
_mockProvidersByPrefix[lockNamePrefix] = mockProvider;
|
||||
}
|
||||
return mockProvider;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes mocking for the specified lock prefix, allowing it to use the real provider.
|
||||
/// </summary>
|
||||
public void Unmock(string lockNamePrefix)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_mockProvidersByPrefix.Remove(lockNamePrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all mock configurations, resetting to real provider for all locks.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var mockProvider in _mockProvidersByPrefix.Values)
|
||||
{
|
||||
mockProvider.Reset();
|
||||
}
|
||||
_mockProvidersByPrefix.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a lock that will be mocked if it matches a configured prefix, otherwise uses the real provider.
|
||||
/// </summary>
|
||||
public IDistributedLock CreateLock(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Check if this lock name matches any configured mock prefix
|
||||
foreach (var (prefix, mockProvider) in _mockProvidersByPrefix)
|
||||
{
|
||||
if (name.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return mockProvider.CreateLock(name);
|
||||
}
|
||||
}
|
||||
|
||||
// No mock configured, use real provider
|
||||
return _realProvider.CreateLock(name);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reset();
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,11 @@ public class TestDistributedLockProvider(IDistributedLockProvider innerProvider)
|
|||
private int _releaseAttemptCount;
|
||||
private string? _targetLockPrefix;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner/real lock provider that this test provider wraps.
|
||||
/// </summary>
|
||||
public IDistributedLockProvider InnerProvider => innerProvider;
|
||||
|
||||
public int AcquisitionAttemptCount => _acquisitionAttemptCount;
|
||||
public int ReleaseAttemptCount => _releaseAttemptCount;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows.ComponentTests.Abstractions;
|
||||
using Elsa.Workflows.ComponentTests.Fixtures;
|
||||
using Elsa.Workflows.Management;
|
||||
|
|
@ -7,18 +8,114 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
|
||||
namespace Elsa.Workflows.ComponentTests.Scenarios.Multitenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for multitenancy tenant ID normalization.
|
||||
/// </summary>
|
||||
public class MultitenancyTests(App app) : AppComponentTest(app)
|
||||
{
|
||||
[Fact(Skip = "Multitenancy disabled. This test doesn't work because not all workflows are assigned the Tenant1 tenant.")]
|
||||
public async Task LoadingWorkflows_ShouldReturnWorkflows_FromCurrentTenant()
|
||||
[Fact]
|
||||
public void DefaultTenant_ShouldUseEmptyStringAsId()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, Tenant.DefaultTenantId);
|
||||
Assert.Equal(Tenant.DefaultTenantId, Tenant.Default.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeTenantId_WithNull_ShouldReturnEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
string? tenantId = null;
|
||||
|
||||
// Act
|
||||
var normalizedId = tenantId.NormalizeTenantId();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Tenant.DefaultTenantId, normalizedId);
|
||||
Assert.Equal(string.Empty, normalizedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeTenantId_WithEmptyString_ShouldReturnEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = string.Empty;
|
||||
|
||||
// Act
|
||||
var normalizedId = tenantId.NormalizeTenantId();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Tenant.DefaultTenantId, normalizedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeTenantId_WithValidTenantId_ShouldReturnSameValue()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = "tenant-123";
|
||||
|
||||
// Act
|
||||
var normalizedId = tenantId.NormalizeTenantId();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant-123", normalizedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowDefinitionStore_ShouldWorkWithTenantNormalization()
|
||||
{
|
||||
// Arrange
|
||||
var store = Scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
|
||||
var filter = new WorkflowDefinitionFilter
|
||||
{
|
||||
IsSystem = false,
|
||||
VersionOptions = VersionOptions.Latest
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw exceptions related to tenant ID handling
|
||||
var workflows = await store.FindManyAsync(filter);
|
||||
Assert.All(workflows, workflow => Assert.Equal("Tenant1", workflow.TenantId));
|
||||
Assert.NotNull(workflows);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantResolverContext_FindTenant_WithNull_ShouldNormalize()
|
||||
{
|
||||
// Arrange
|
||||
var defaultTenant = new Tenant { Id = Tenant.DefaultTenantId, Name = "Default" };
|
||||
var tenant1 = new Tenant { Id = "tenant1", Name = "Tenant 1" };
|
||||
var tenantsDictionary = new Dictionary<string, Tenant>
|
||||
{
|
||||
{ defaultTenant.Id, defaultTenant },
|
||||
{ tenant1.Id, tenant1 }
|
||||
};
|
||||
var context = new TenantResolverContext(tenantsDictionary, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
string? nullTenantId = null;
|
||||
var result = context.FindTenant(nullTenantId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Tenant.DefaultTenantId, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantResolverContext_FindTenant_WithEmptyString_ShouldFindDefaultTenant()
|
||||
{
|
||||
// Arrange
|
||||
var defaultTenant = new Tenant { Id = Tenant.DefaultTenantId, Name = "Default" };
|
||||
var tenantsDictionary = new Dictionary<string, Tenant>
|
||||
{
|
||||
{ defaultTenant.Id, defaultTenant }
|
||||
};
|
||||
var context = new TenantResolverContext(tenantsDictionary, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = context.FindTenant(string.Empty);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Tenant.DefaultTenantId, result.Id);
|
||||
Assert.Equal("Default", result.Name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ using Elsa.Workflows.ComponentTests.Abstractions;
|
|||
using Elsa.Workflows.ComponentTests.Fixtures;
|
||||
using Elsa.Workflows.ComponentTests.Scenarios.WorkflowActivities.Workflows;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity;
|
||||
using Elsa.Workflows.Management.Contracts;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowActivities;
|
||||
|
|
@ -34,8 +37,23 @@ public class DeleteWorkflowTests : AppComponentTest
|
|||
EnsureWorkflowInRegistry(_scope1, Workflows.DeleteWorkflow.Type);
|
||||
|
||||
var workflowDefinitionManager = _scope1.ServiceProvider.GetRequiredService<IWorkflowDefinitionManager>();
|
||||
await workflowDefinitionManager.DeleteByDefinitionIdAsync(Workflows.DeleteWorkflow.DefinitionId);
|
||||
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>();
|
||||
var workflowDefinitionActivityProvider = _scope1.ServiceProvider.GetRequiredService<WorkflowDefinitionActivityProvider>();
|
||||
await activityRegistry.RefreshDescriptorsAsync(workflowDefinitionActivityProvider);
|
||||
|
||||
// Verify the workflow is removed from the registry
|
||||
WorkflowTypeDeletedFromRegistry(_scope1, Workflows.DeleteWorkflow.Type);
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +89,22 @@ public class DeleteWorkflowTests : AppComponentTest
|
|||
Assert.Null(descriptor);
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForWorkflowTypeRemovedAsync(IServiceScope scope, string type, TimeSpan timeout)
|
||||
{
|
||||
var activityRegistry = scope.ServiceProvider.GetRequiredService<IActivityRegistry>();
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (activityRegistry.Find(type) == null)
|
||||
return true;
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OnWorkflowDefinitionDeleted(object? sender, WorkflowDefinitionDeletedEventArgs args)
|
||||
{
|
||||
if (args.DefinitionId == Workflows.DeleteWorkflow.DefinitionId)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Elsa.Testing.Shared;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Helpers;
|
||||
using Elsa.Workflows.Management;
|
||||
|
|
@ -29,7 +30,7 @@ public class Tests
|
|||
DefinitionId: "WorkflowWithTrigger",
|
||||
Version: 1,
|
||||
Id: "1",
|
||||
TenantId: "default"
|
||||
TenantId: Tenant.DefaultTenantId
|
||||
),
|
||||
Root = new Event("Foo")
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
|
||||
namespace Elsa.Common.UnitTests.Multitenancy;
|
||||
|
||||
public class TenantIdNormalizationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void NormalizeTenantId_WithNullOrEmpty_ReturnsDefaultTenantId(string? tenantId)
|
||||
{
|
||||
// Act
|
||||
var result = tenantId.NormalizeTenantId();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(Tenant.DefaultTenantId, result);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tenant1")]
|
||||
[InlineData("tenant-abc-123")]
|
||||
[InlineData("DEFAULT")]
|
||||
[InlineData("my-custom-tenant")]
|
||||
[InlineData(" ")] // Whitespace is not normalized
|
||||
public void NormalizeTenantId_WithNonNullString_ReturnsOriginalValue(string tenantId)
|
||||
{
|
||||
// Act
|
||||
var result = tenantId.NormalizeTenantId();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(tenantId, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTenantId_IsEmptyString()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(Tenant.DefaultTenantId, string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTenant_UsesDefaultTenantId()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(Tenant.DefaultTenantId, Tenant.Default.Id);
|
||||
Assert.Equal(string.Empty, Tenant.Default.Id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
|
||||
namespace Elsa.Common.UnitTests.Multitenancy;
|
||||
|
||||
public class TenantResolverContextTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, "Default")]
|
||||
[InlineData("", "Default")]
|
||||
[InlineData("tenant1", "Tenant 1")]
|
||||
[InlineData("tenant2", "Tenant 2")]
|
||||
public void FindTenant_ById_FindsCorrectTenant(string? tenantId, string expectedName)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = context.FindTenant(tenantId!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedName, result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindTenant_WithNonExistentId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = context.FindTenant("non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Alpha", "tenant1", "Tenant Alpha")]
|
||||
[InlineData("Beta", "tenant2", "Tenant Beta")]
|
||||
public void FindTenant_WithPredicate_FindsMatchingTenant(string searchTerm, string expectedId, string expectedName)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContextWithNamedTenants();
|
||||
|
||||
// Act
|
||||
var result = context.FindTenant(t => t.Name.Contains(searchTerm));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedId, result.Id);
|
||||
Assert.Equal(expectedName, result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindTenant_WithPredicate_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = context.FindTenant(t => t.Name == "NonExistent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_StoresCancellationToken()
|
||||
{
|
||||
// Arrange
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var context = new TenantResolverContext(new Dictionary<string, Tenant>(), cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cts.Token, context.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindTenant_NormalizesNullAndEmptyStringToSameValue()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var resultFromNull = context.FindTenant((string?)null);
|
||||
var resultFromEmptyString = context.FindTenant(string.Empty);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resultFromNull);
|
||||
Assert.NotNull(resultFromEmptyString);
|
||||
Assert.Same(resultFromNull, resultFromEmptyString);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static TenantResolverContext CreateContext()
|
||||
{
|
||||
var tenants = new Dictionary<string, Tenant>
|
||||
{
|
||||
{
|
||||
Tenant.DefaultTenantId, new()
|
||||
{
|
||||
Id = Tenant.DefaultTenantId,
|
||||
Name = "Default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tenant1", new()
|
||||
{
|
||||
Id = "tenant1",
|
||||
Name = "Tenant 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tenant2", new()
|
||||
{
|
||||
Id = "tenant2",
|
||||
Name = "Tenant 2"
|
||||
}
|
||||
}
|
||||
};
|
||||
return new(tenants, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static TenantResolverContext CreateContextWithNamedTenants()
|
||||
{
|
||||
var tenants = new Dictionary<string, Tenant>
|
||||
{
|
||||
{
|
||||
Tenant.DefaultTenantId, new()
|
||||
{
|
||||
Id = Tenant.DefaultTenantId,
|
||||
Name = "Default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tenant1", new()
|
||||
{
|
||||
Id = "tenant1",
|
||||
Name = "Tenant Alpha"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tenant2", new()
|
||||
{
|
||||
Id = "tenant2",
|
||||
Name = "Tenant Beta"
|
||||
}
|
||||
}
|
||||
};
|
||||
return new(tenants, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Include>[Elsa.Tenants]*</Include>
|
||||
<Threshold>0</Threshold>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Tenants\Elsa.Tenants.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Common\Elsa.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Tenants.Options;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.Tenants.UnitTests.Services;
|
||||
|
||||
public class DefaultTenantResolverPipelineInvokerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithEmptyStringTenantId_FindsDefaultTenant()
|
||||
{
|
||||
// Arrange
|
||||
var tenants = CreateDefaultTenantList();
|
||||
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved(""));
|
||||
|
||||
// Act
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Default", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithValidTenantId_FindsCorrectTenant()
|
||||
{
|
||||
// Arrange
|
||||
var tenants = CreateDefaultTenantList();
|
||||
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved("tenant1"));
|
||||
|
||||
// Act
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Tenant 1", result.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithNonExistentTenantId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tenants = CreateDefaultTenantList();
|
||||
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved("non-existent"));
|
||||
|
||||
// Act
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithNullTenantIdsInList_DoesNotThrowDictionaryException()
|
||||
{
|
||||
// Arrange - Simulates legacy data with null IDs
|
||||
var tenants = new List<Tenant>
|
||||
{
|
||||
new() { Id = null!, Name = "Legacy Null Tenant" },
|
||||
new() { Id = "tenant1", Name = "Tenant 1" }
|
||||
};
|
||||
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Unresolved());
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithUnresolvedResult_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tenants = CreateDefaultTenantList();
|
||||
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Unresolved());
|
||||
|
||||
// Act
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithMultipleResolvers_UsesFirstResolvedResult()
|
||||
{
|
||||
// Arrange
|
||||
var tenants = CreateDefaultTenantList();
|
||||
var mockResolver1 = CreateMockResolver(TenantResolverResult.Resolved("tenant1"));
|
||||
var mockResolver2 = CreateMockResolver(TenantResolverResult.Resolved("tenant2"));
|
||||
var invoker = CreateInvokerWithMultipleResolvers(tenants, mockResolver1, mockResolver2);
|
||||
|
||||
// Act
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("tenant1", result.Id);
|
||||
await mockResolver2.DidNotReceive().ResolveAsync(Arg.Any<TenantResolverContext>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokePipelineAsync_WithLegacyNullTenantIds_NormalizesAndFindsDefaultTenant()
|
||||
{
|
||||
// Arrange - Simulates legacy data with null ID that gets normalized
|
||||
var tenants = new List<Tenant>
|
||||
{
|
||||
new() { Id = null!, Name = "Legacy" }, // Will be normalized to ""
|
||||
new() { Id = "tenant1", Name = "Tenant 1" }
|
||||
};
|
||||
var (invoker, _) = CreateInvoker(tenants, TenantResolverResult.Resolved(""));
|
||||
|
||||
// Act
|
||||
var result = await invoker.InvokePipelineAsync();
|
||||
|
||||
// Assert - The null tenant ID gets normalized to "" in dictionary, so it should be found
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Legacy", result.Name); // Should find the Legacy tenant (normalized from null)
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static List<Tenant> CreateDefaultTenantList() => new()
|
||||
{
|
||||
new() { Id = Tenant.DefaultTenantId, Name = "Default" },
|
||||
new() { Id = "tenant1", Name = "Tenant 1" },
|
||||
new() { Id = "tenant2", Name = "Tenant 2" }
|
||||
};
|
||||
|
||||
private static ITenantResolver CreateMockResolver(TenantResolverResult result)
|
||||
{
|
||||
var mockResolver = Substitute.For<ITenantResolver>();
|
||||
mockResolver.ResolveAsync(Arg.Any<TenantResolverContext>()).Returns(result);
|
||||
return mockResolver;
|
||||
}
|
||||
|
||||
private static (DefaultTenantResolverPipelineInvoker Invoker, ITenantResolver Resolver) CreateInvoker(
|
||||
List<Tenant> tenants,
|
||||
TenantResolverResult resolverResult)
|
||||
{
|
||||
var tenantsProvider = Substitute.For<ITenantsProvider>();
|
||||
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(tenants);
|
||||
|
||||
var mockResolver = CreateMockResolver(resolverResult);
|
||||
|
||||
// Use a mock pipeline builder that directly returns our mock resolver
|
||||
var pipelineBuilder = Substitute.For<ITenantResolverPipelineBuilder>();
|
||||
pipelineBuilder.Build(Arg.Any<IServiceProvider>()).Returns(new[] { mockResolver });
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new MultitenancyOptions
|
||||
{
|
||||
TenantResolverPipelineBuilder = pipelineBuilder
|
||||
});
|
||||
|
||||
var serviceProvider = Substitute.For<IServiceProvider>();
|
||||
var logger = NullLogger<DefaultTenantResolverPipelineInvoker>.Instance;
|
||||
var invoker = new DefaultTenantResolverPipelineInvoker(options, tenantsProvider, serviceProvider, logger);
|
||||
|
||||
return (invoker, mockResolver);
|
||||
}
|
||||
|
||||
private static DefaultTenantResolverPipelineInvoker CreateInvokerWithMultipleResolvers(
|
||||
List<Tenant> tenants,
|
||||
params ITenantResolver[] resolvers)
|
||||
{
|
||||
var tenantsProvider = Substitute.For<ITenantsProvider>();
|
||||
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(tenants);
|
||||
|
||||
// Use a mock pipeline builder that directly returns our mock resolvers
|
||||
var pipelineBuilder = Substitute.For<ITenantResolverPipelineBuilder>();
|
||||
pipelineBuilder.Build(Arg.Any<IServiceProvider>()).Returns(resolvers);
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new MultitenancyOptions
|
||||
{
|
||||
TenantResolverPipelineBuilder = pipelineBuilder
|
||||
});
|
||||
|
||||
var serviceProvider = Substitute.For<IServiceProvider>();
|
||||
var logger = NullLogger<DefaultTenantResolverPipelineInvoker>.Instance;
|
||||
return new(options, tenantsProvider, serviceProvider, logger);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Models;
|
||||
|
||||
namespace Elsa.Workflows.Core.UnitTests.Models;
|
||||
|
||||
public class ActivityConstructionResultTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2, true)]
|
||||
public void Constructor_WithVaryingExceptionCounts_SetsPropertiesCorrectly(int exceptionCount, bool expectedHasExceptions)
|
||||
{
|
||||
// Arrange
|
||||
var activity = CreateActivity();
|
||||
var exceptions = CreateExceptions(exceptionCount);
|
||||
|
||||
// Act
|
||||
var result = new ActivityConstructionResult(activity, exceptions);
|
||||
|
||||
// Assert
|
||||
Assert.Same(activity, result.Activity);
|
||||
Assert.Equal(exceptionCount, result.Exceptions.Count());
|
||||
Assert.Equal(expectedHasExceptions, result.HasExceptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullExceptions_TreatsAsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var activity = CreateActivity();
|
||||
|
||||
// Act
|
||||
var result = new ActivityConstructionResult(activity, null);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Exceptions);
|
||||
Assert.False(result.HasExceptions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(3, true)]
|
||||
public void Cast_PreservesActivityAndExceptions(int exceptionCount, bool expectedHasExceptions)
|
||||
{
|
||||
// Arrange
|
||||
var activity = CreateActivity();
|
||||
var exceptions = CreateExceptions(exceptionCount);
|
||||
var result = new ActivityConstructionResult(activity, exceptions);
|
||||
|
||||
// Act
|
||||
var typedResult = result.Cast<WriteLine>();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ActivityConstructionResult<WriteLine>>(typedResult);
|
||||
Assert.Same(activity, typedResult.Activity);
|
||||
Assert.Equal(exceptionCount, typedResult.Exceptions.Count());
|
||||
Assert.Equal(expectedHasExceptions, typedResult.HasExceptions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2, true)]
|
||||
public void GenericConstructor_CreatesTypedResultWithInheritance(int exceptionCount, bool expectedHasExceptions)
|
||||
{
|
||||
// Arrange
|
||||
var activity = CreateActivity();
|
||||
var exceptions = CreateExceptions(exceptionCount);
|
||||
|
||||
// Act
|
||||
var result = new ActivityConstructionResult<WriteLine>(activity, exceptions);
|
||||
|
||||
// Assert
|
||||
Assert.Same(activity, result.Activity);
|
||||
Assert.Equal(exceptionCount, result.Exceptions.Count());
|
||||
Assert.Equal(expectedHasExceptions, result.HasExceptions);
|
||||
Assert.IsAssignableFrom<ActivityConstructionResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exceptions_CanBeEnumerated()
|
||||
{
|
||||
// Arrange
|
||||
var activity = CreateActivity();
|
||||
var exceptions = CreateExceptions(3);
|
||||
var result = new ActivityConstructionResult(activity, exceptions);
|
||||
|
||||
// Act & Assert
|
||||
var count = 0;
|
||||
foreach (var ex in result.Exceptions)
|
||||
{
|
||||
Assert.NotNull(ex);
|
||||
count++;
|
||||
}
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static WriteLine CreateActivity() => new("test");
|
||||
|
||||
private static List<Exception>? CreateExceptions(int count)
|
||||
{
|
||||
if (count == 0) return null;
|
||||
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new InvalidOperationException($"Error {i}") as Exception)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,170 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.Workflows.Core.UnitTests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActivityRegistry, specifically testing multi-tenant descriptor resolution logic and refresh behavior.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshDescriptorsAsync_CalledTwice_DoesNotLogWarnings()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger<ActivityRegistry>>();
|
||||
var mockActivityDescriber = new Mock<IActivityDescriber>();
|
||||
var modifiers = Array.Empty<IActivityDescriptorModifier>();
|
||||
|
||||
var registry = new ActivityRegistry(mockActivityDescriber.Object, modifiers, mockLogger.Object);
|
||||
|
||||
var mockProvider = new Mock<IActivityProvider>();
|
||||
var mockProvider = Substitute.For<IActivityProvider>();
|
||||
var descriptor1 = new ActivityDescriptor
|
||||
{
|
||||
TypeName = "TestActivity1",
|
||||
|
|
@ -38,30 +185,27 @@ public class ActivityRegistryTests
|
|||
IsBrowsable = true
|
||||
};
|
||||
|
||||
mockProvider.Setup(p => p.GetDescriptorsAsync(It.IsAny<CancellationToken>()))
|
||||
mockProvider.GetDescriptorsAsync(Arg.Any<CancellationToken>())
|
||||
.ReturnsAsync(new[] { descriptor1, descriptor2 });
|
||||
|
||||
var providers = new[] { mockProvider.Object };
|
||||
|
||||
// Act - First refresh
|
||||
await registry.RefreshDescriptorsAsync(providers);
|
||||
await _registry.RefreshDescriptorsAsync(providers);
|
||||
|
||||
// Act - Second refresh (simulates the intentional repopulation in DefaultRegistriesPopulator)
|
||||
await registry.RefreshDescriptorsAsync(providers);
|
||||
await _registry.RefreshDescriptorsAsync(providers);
|
||||
|
||||
// Assert - Verify no warning logs were made
|
||||
mockLogger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("was already registered")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never,
|
||||
"Expected no duplicate registration warnings during refresh");
|
||||
_logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>((v, t) => v.ToString()!.Contains("was already registered")),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
|
||||
// Verify descriptors are still registered
|
||||
var allDescriptors = registry.ListAll().ToList();
|
||||
var allDescriptors = _registry.ListAll().ToList();
|
||||
Assert.Equal(2, allDescriptors.Count);
|
||||
Assert.Contains(allDescriptors, d => d.TypeName == "TestActivity1");
|
||||
Assert.Contains(allDescriptors, d => d.TypeName == "TestActivity2");
|
||||
|
|
@ -71,12 +215,8 @@ public class ActivityRegistryTests
|
|||
public async Task RefreshDescriptorsAsync_PreservesManualDescriptors()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger<ActivityRegistry>>();
|
||||
var mockActivityDescriber = new Mock<IActivityDescriber>();
|
||||
var modifiers = Array.Empty<IActivityDescriptorModifier>();
|
||||
|
||||
var registry = new ActivityRegistry(mockActivityDescriber.Object, modifiers, mockLogger.Object);
|
||||
|
||||
// Create a manual descriptor
|
||||
var manualDescriptor = new ActivityDescriptor
|
||||
{
|
||||
|
|
@ -88,10 +228,10 @@ public class ActivityRegistryTests
|
|||
IsBrowsable = true
|
||||
};
|
||||
|
||||
registry.Register(manualDescriptor);
|
||||
_registry.Register(manualDescriptor);
|
||||
|
||||
// Create a provider descriptor
|
||||
var mockProvider = new Mock<IActivityProvider>();
|
||||
var mockProvider = Substitute.For<IActivityProvider>();
|
||||
var providerDescriptor = new ActivityDescriptor
|
||||
{
|
||||
TypeName = "ProviderActivity",
|
||||
|
|
@ -102,45 +242,38 @@ public class ActivityRegistryTests
|
|||
IsBrowsable = true
|
||||
};
|
||||
|
||||
mockProvider.Setup(p => p.GetDescriptorsAsync(It.IsAny<CancellationToken>()))
|
||||
mockProvider.GetDescriptorsAsync(Arg.Any<CancellationToken>())
|
||||
.ReturnsAsync(new[] { providerDescriptor });
|
||||
|
||||
var providers = new[] { mockProvider.Object };
|
||||
|
||||
// Act - Refresh with provider
|
||||
await registry.RefreshDescriptorsAsync(providers);
|
||||
await _registry.RefreshDescriptorsAsync(providers);
|
||||
|
||||
// Assert - Both manual and provider descriptors should be present
|
||||
var allDescriptors = registry.ListAll().ToList();
|
||||
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
|
||||
mockLogger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ManualActivity")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never,
|
||||
"Expected no warnings about manual descriptor");
|
||||
_logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>((v, t) => v.ToString()!.Contains("ManualActivity")),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshDescriptorsAsync_LogsWarning_WhenDifferentProvidersRegisterSameActivity()
|
||||
{
|
||||
// Arrange
|
||||
var mockLogger = new Mock<ILogger<ActivityRegistry>>();
|
||||
var mockActivityDescriber = new Mock<IActivityDescriber>();
|
||||
var modifiers = Array.Empty<IActivityDescriptorModifier>();
|
||||
|
||||
var registry = new ActivityRegistry(mockActivityDescriber.Object, modifiers, mockLogger.Object);
|
||||
|
||||
// Create two different providers with the same activity
|
||||
var mockProvider1 = new Mock<IActivityProvider>();
|
||||
var mockProvider2 = new Mock<IActivityProvider>();
|
||||
var mockProvider1 = Substitute.For<IActivityProvider>();
|
||||
var mockProvider2 = Substitute.For<IActivityProvider>();
|
||||
|
||||
var descriptor1 = new ActivityDescriptor
|
||||
{
|
||||
|
|
@ -162,26 +295,23 @@ public class ActivityRegistryTests
|
|||
IsBrowsable = true
|
||||
};
|
||||
|
||||
mockProvider1.Setup(p => p.GetDescriptorsAsync(It.IsAny<CancellationToken>()))
|
||||
mockProvider1.GetDescriptorsAsync(Arg.Any<CancellationToken>())
|
||||
.ReturnsAsync(new[] { descriptor1 });
|
||||
|
||||
mockProvider2.Setup(p => p.GetDescriptorsAsync(It.IsAny<CancellationToken>()))
|
||||
mockProvider2.GetDescriptorsAsync(Arg.Any<CancellationToken>())
|
||||
.ReturnsAsync(new[] { descriptor2 });
|
||||
|
||||
var providers = new[] { mockProvider1.Object, mockProvider2.Object };
|
||||
|
||||
// Act
|
||||
await registry.RefreshDescriptorsAsync(providers);
|
||||
await _registry.RefreshDescriptorsAsync(providers);
|
||||
|
||||
// Assert - Should log a warning for the duplicate
|
||||
mockLogger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("DuplicateActivity") && v.ToString()!.Contains("was already registered")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once,
|
||||
"Expected one warning for duplicate activity from different providers");
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>((v, t) => v.ToString()!.Contains("DuplicateActivity") && v.ToString()!.Contains("was already registered")),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Open.Linq.AsyncExtensions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.Workflows.Runtime.UnitTests.Services;
|
||||
|
|
@ -27,6 +28,7 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
|
|||
Substitute.For<IPayloadSerializer>(),
|
||||
Substitute.For<ISystemClock>(),
|
||||
Substitute.For<IIdentityGraphService>(),
|
||||
Substitute.For<ITenantAccessor>(),
|
||||
Substitute.For<ILogger<DefaultWorkflowDefinitionStorePopulator>>());
|
||||
}
|
||||
|
||||
|
|
@ -227,4 +229,91 @@ public class DefaultWorkflowDefinitionStorePopulatorTests
|
|||
await _storeMock.Received(count)
|
||||
.SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "PopulateStoreAsync imports workflows from current tenant")]
|
||||
public async Task PopulateStoreAsync_CurrentTenantWorkflows_ImportsWorkflows()
|
||||
{
|
||||
var currentTenantId = "tenant-1";
|
||||
|
||||
var workflow1 = CreateMaterializedWorkflow("workflow-1", "id-1", currentTenantId);
|
||||
var workflow2 = CreateMaterializedWorkflow("workflow-2", "id-2", currentTenantId);
|
||||
|
||||
var populator = CreatePopulatorWithTenant(currentTenantId, workflow1, workflow2);
|
||||
var result = await populator.PopulateStoreAsync();
|
||||
|
||||
Assert.Equal(2, result.Count());
|
||||
await _storeMock.Received(2).SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "PopulateStoreAsync skips workflows from other tenants")]
|
||||
public async Task PopulateStoreAsync_OtherTenantWorkflows_SkipsWorkflows()
|
||||
{
|
||||
var currentTenantId = "tenant-1";
|
||||
var otherTenantId = "tenant-2";
|
||||
|
||||
var workflowCurrentTenant = CreateMaterializedWorkflow("workflow-1", "id-1", currentTenantId);
|
||||
var workflowOtherTenant = CreateMaterializedWorkflow("workflow-2", "id-2", otherTenantId);
|
||||
|
||||
var populator = CreatePopulatorWithTenant(currentTenantId, workflowCurrentTenant, workflowOtherTenant);
|
||||
var result = await populator.PopulateStoreAsync().ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("workflow-1", result.First().DefinitionId);
|
||||
await _storeMock.Received(1).SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "PopulateStoreAsync handles null/empty tenant IDs correctly")]
|
||||
[InlineData(null, null, true)] // Both null - should import
|
||||
[InlineData("", "", true)] // Both empty - should import
|
||||
[InlineData(null, "", true)] // Normalized as same - should import
|
||||
[InlineData("tenant-1", null, false)] // Different tenants - should skip
|
||||
[InlineData("tenant-1", "", false)] // Different tenants - should skip
|
||||
public async Task PopulateStoreAsync_NullOrEmptyTenantIds_HandlesCorrectly(string? currentTenantId, string? workflowTenantId, bool shouldImport)
|
||||
{
|
||||
var workflow = CreateMaterializedWorkflow("workflow-1", "id-1", workflowTenantId);
|
||||
var populator = CreatePopulatorWithTenant(currentTenantId, workflow);
|
||||
var result = await populator.PopulateStoreAsync();
|
||||
|
||||
if (shouldImport)
|
||||
{
|
||||
Assert.Single(result);
|
||||
await _storeMock.Received(1).SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(result);
|
||||
await _storeMock.DidNotReceive().SaveManyAsync(Arg.Any<IEnumerable<WorkflowDefinition>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
private MaterializedWorkflow CreateMaterializedWorkflow(string definitionId, string id, string? tenantId)
|
||||
{
|
||||
return new(new()
|
||||
{
|
||||
Identity = new(definitionId, 1, id, tenantId),
|
||||
Publication = new(true, true)
|
||||
}, "Test", "TestProvider");
|
||||
}
|
||||
|
||||
private DefaultWorkflowDefinitionStorePopulator CreatePopulatorWithTenant(string? tenantId, params MaterializedWorkflow[] workflows)
|
||||
{
|
||||
var tenantAccessor = Substitute.For<ITenantAccessor>();
|
||||
tenantAccessor.Tenant.Returns(tenantId != null ? new Tenant { Id = tenantId } : null);
|
||||
|
||||
var provider = Substitute.For<IWorkflowsProvider>();
|
||||
provider.Name.Returns("TestProvider");
|
||||
provider.GetWorkflowsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<IEnumerable<MaterializedWorkflow>>(workflows));
|
||||
|
||||
return new(
|
||||
() => new List<IWorkflowsProvider> { provider },
|
||||
Substitute.For<ITriggerIndexer>(),
|
||||
_storeMock,
|
||||
Substitute.For<IActivitySerializer>(),
|
||||
Substitute.For<IPayloadSerializer>(),
|
||||
Substitute.For<ISystemClock>(),
|
||||
Substitute.For<IIdentityGraphService>(),
|
||||
tenantAccessor,
|
||||
Substitute.For<ILogger<DefaultWorkflowDefinitionStorePopulator>>());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue