elsa-core/doc/adr/0005-token-centric-flowchart-execution-model.md
Sipke Schoorstra b09a564812
Fix Multitenancy Support and Normalize Tenant ID Handling (#7217)
* Enable multitenancy support and normalize tenant ID handling.

- Activate multitenancy in `Program.cs`.
- Introduce `NormalizeTenantId` method for consistent tenant ID usage.
- Update tenant-related classes and features to support normalization logic.

* Add ADR for adopting empty string as the default tenant ID

- Standardized the tenant ID for the default tenant to use an empty string (`""`) instead of `null`.
- Documented the rationale and migration considerations in ADR 0007.
- Updated ADR table of contents and graph for new entry.

* Apply suggestion from @sfmskywalker

* Update doc/adr/graph.dot

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

* Normalize spacing and improve readability in `Program.cs`. Fix multitenancy condition formatting.

* Fix ADR numbering and update TOC

* Add ADRs for flowchart execution model, tenant deletion event, merge modes, and default tenant ID

- Introduced ADR 0005: Token-centric flowchart execution model for improved loop and join handling.
- Added ADR 0006: Tenant Deleted event for distinct handling of tenant removal.
- Documented ADR 0007: Explicit merge modes for flowchart joins, improving reliability and configurability.
- Included ADR 0008: Standardization of empty string as the default tenant ID for consistency and clarity.

* Add unit tests for tenant ID normalization and multitenancy pipeline invoker

- Added comprehensive unit tests for tenant ID normalization to ensure consistent handling of null, empty, and valid IDs.
- Introduced tests for the multitenancy pipeline invoker covering various tenant resolution scenarios.
- Updated solution to include new unit testing projects for `Elsa.Tenants` and `Elsa.Common`.

* Update unit tests for `ActivityConstructionResult`

- Refactor test parameterization to verify `HasExceptions` property more explicitly.
- Simplify exception creation logic in helper methods.
- Improve test assertions by combining act and assert phases where applicable.

* Enable configuration-based multitenancy with tenant-specific settings

- Introduced a configuration-based tenant provider to streamline tenant initialization and customization.
- Added tenant ID handling filters to ensure tenant ID is applied and filtered automatically.
- Deprecated the `CommonPersistenceFeature` in favor of modular persistence feature extension.

* Update database indexes to include `TenantId` for multitenancy support

- Added `TenantId` to unique constraints on `Triggers` table across all EFCore providers.
- Adjusted index names to reflect the updated constraints.
- Updated trigger configuration to ensure uniqueness includes `TenantId`.

* Add tenant filtering to `DefaultWorkflowDefinitionStorePopulator`

- Introduced `ITenantAccessor` to support tenant-specific filtering of workflow definitions.
- Updated logic to skip workflows not matching the current tenant.

* Update doc/adr/toc.md

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

* Remove `CommonPersistenceFeature` as it has been deprecated

* Add tenant-specific filtering to workflow import logic in `DefaultWorkflowDefinitionStorePopulator`

* Replace hardcoded tenant ID with `Tenant.DefaultTenantId` in integration tests

* Update database indexes and migration logic to support `TenantId` for multitenancy

- Added `TenantId` to unique constraints on the `Triggers` table and updated index names.
- Included logic to drop outdated indexes without `TenantId` during migration.
- Adjusted tests to account for `TenantId` in workflow identity and indexing scenarios.

* Remove `TenantId` from workflow identity construction in concurrent trigger indexing tests

* Introduce `SelectiveMockLockProvider` for precise lock mocking in tests

- Added `SelectiveMockLockProvider` to allow targeted lock mocking without affecting unrelated background operations.
- Updated test services to use `SelectiveMockLockProvider` in place of `TestDistributedLockProvider`.
- Refactored `DistributedLockResilienceTests` to support selective mocking for deterministic and reliable assertions.

* Update Elsa.sln

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

* Normalize tenant ID handling in `DefaultWorkflowDefinitionStorePopulator` for consistent filtering

* Refactor `TenantResolverResult` to support explicit resolved/unresolved state handling

- Updated `TenantResolverResult` to include an explicit `_isResolved` property.
- Adjusted `ResolveTenantId()` and `IsResolved` logic for improved clarity and robustness.
- Simplified tenant resolution invocation in `TenantResolverBase`.
- Removed redundant normalization in `DefaultTenantResolverPipelineInvoker`.

* Normalize tenant ID handling in `DefaultWorkflowDefinitionStorePopulator` and `ClrWorkflowsProvider`.

* Refactor `DefaultWorkflowDefinitionStorePopulatorTests`: streamline object initializations and add tenant-specific test coverage for `PopulateStoreAsync`.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 19:54:13 +01:00

3.3 KiB
Raw Permalink Blame History

5. Token-Centric Flowchart Execution Model

Date: 2025-05-06

Status

Accepted

Context

Elsa Workflows original flowchart used execution-count heuristics to drive joins, which fails in loops, XOR splits and resumable activities:

  • Loop-back edges never emit a “forward” token, stalling AND-joins.
  • Counting executions across iterations causes premature or missed firings.
  • Resumable activities (e.g. Delay) clear join state on resume.
  • Users cannot declaratively control join semantics without deep framework hacks.

We need a model that:

  1. Handles loops, forks, XORs and resumable activities reliably.
  2. Lets designers choose per-activity join behavior.
  3. Cleans up state to avoid memory leaks.
  4. Supports cancellation of in-flight branches.

Decision

Adopt a token-centric execution model with explicit MergeMode and blocking:

  1. Tokens

    • On each activity completion, for each active outbound connection, emit a Token with:
      • FromActivityId, Outcome, ToActivityId,
      • Flags: Consumed = false, Blocked = false.
    • Persist the list in ActivityExecutionContext.Properties["Flowchart.Tokens"].
  2. MergeMode

    • Query each target activitys MergeMode via GetMergeModeAsync(...). Supported values:
      • Race: “first wins”
      • Stream: “first wins, but dont cancel ancestors”
      • Converge (default): “wait for all”
    • Race
      1. Cancel inbound ancestors (CancelInboundAncestorsAsync).
      2. If no existing blocked token for this inbound connection, schedule the target and then block all other inbound branches by emitting Token.Block() for each.
      3. Subsequent branches see their blocked token and simply consume it.
    • Stream
      • Same as Race except you do not cancel inbound ancestors.
    • Converge
      • Wait until every inbound connection for the target has at least one unblocked, unconsumed token. Then schedule once.
  3. Scheduling Loop
    On each child completion:

    • Emit tokens for its outbound edges.
    • Consume any tokens whose ToActivityId matches the completed activity.
    • For each active outbound connection, inspect its targets MergeMode and apply the rules above to decide whether to schedule it.
  4. State Cleanup

    • After scheduling (or skipping) a target, remove any consumed tokens whose ToActivityId equals the completed activity.
    • When the flow has no pending work (HasPendingWork() is false), clear the entire token list and complete the flowchart.
    • On activity cancellation (OnTokenFlowActivityCanceledAsync), remove all tokens from or to that activity, then re-check for completion.

Sequence Diagram

sequenceDiagram
    participant A as Activity A
    participant F as Flowchart
    participant B as Activity B

    A-->>F: Completed(outcome="Done")
    F->>F: Emit Token(A→B,Block=false,Consumed=false)
    F->>F: Consume any inbound tokens for A
    F->>F: Get B.MergeMode()
    alt Race & first branch
      F->>F: CancelInboundAncestors(B)
      F-->>B: Schedule B
      F->>F: Emit blocked Tokens for other inbound edges into B
    else Race & later branch
      F->>F: Consume blocked Token
    else Converge until all arrived
      Note over F: wait
    end
    F->>F: Purge consumed tokens for A
    F-->>F: Complete if no pending work