|
| 1 | +# Plan: Activity Execution Call Stack Implementation (Hybrid: explicit + ambient) |
| 2 | + |
| 3 | +This plan implements a comprehensive call stack mechanism to track the execution chain from root workflow through all parent activities to a specific activity execution, enabling visibility into the complete invocation hierarchy when viewing activity execution records. |
| 4 | + |
| 5 | +Core design |
| 6 | +- Explicit predecessor: Activities that know the causal predecessor (e.g., a completed child that schedules the next) set `SchedulingActivityExecutionId` directly via scheduling options. |
| 7 | +- Ambient fallback: During completion callbacks, bookmark resumes, and child-workflow starts, the workflow sets an ambient "current scheduling source" on the `WorkflowExecutionContext`. If a schedule call omits `SchedulingActivityExecutionId`, the scheduler fills it from the ambient. This minimizes code churn while preserving correctness. |
| 8 | +- Structural vs temporal: Keep `Owner`/`ParentActivityExecutionContext` for structural containment; use `SchedulingActivityExecutionId`/`SchedulingWorkflowInstanceId` for the temporal execution chain. |
| 9 | + |
| 10 | +## Steps |
| 11 | + |
| 12 | +### 1. Add call stack fields to scheduling models throughout the chain |
| 13 | + |
| 14 | +Add `SchedulingActivityExecutionId` (nullable `string`) to: |
| 15 | +- [`ScheduleWorkOptions`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Options/ScheduleWorkOptions.cs) |
| 16 | +- [`ScheduledActivityOptions`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Models/ScheduledActivityOptions.cs) |
| 17 | +- [`ActivityWorkItem`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Models/ActivityWorkItem.cs) |
| 18 | +- [`ActivityInvocationOptions`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Options/ActivityInvocationOptions.cs) |
| 19 | + |
| 20 | +Include clear XML documentation explaining this tracks the temporal/execution predecessor (distinct from structural `Owner`/`ParentActivityExecutionContext`). |
| 21 | + |
| 22 | +Update all constructors and property mappings in: |
| 23 | +- [`WorkflowExecutionContextSchedulerStrategy.Schedule`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs) |
| 24 | +- [`DefaultActivitySchedulerMiddleware.ExecuteWorkItemAsync`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs) |
| 25 | + |
| 26 | +Thread this value through the scheduling chain. |
| 27 | + |
| 28 | +### 2. Store call stack fields in runtime and persisted contexts |
| 29 | + |
| 30 | +Add the following fields to [`ActivityExecutionContext`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs): |
| 31 | +- `SchedulingActivityExecutionId` (nullable `string`) |
| 32 | +- `SchedulingActivityId` (nullable `string` - denormalized for convenience) |
| 33 | +- `SchedulingWorkflowInstanceId` (nullable `string` - for cross-workflow tracking) |
| 34 | + |
| 35 | +Include XML comments distinguishing these from `ParentActivityExecutionContext`: |
| 36 | +- **`ParentActivityExecutionContext`**: The structural container activity (e.g., Flowchart contains all its children). Represents the hierarchical parent in the workflow structure. |
| 37 | +- **`SchedulingActivityExecutionId`**: The temporal/execution predecessor that directly triggered execution of this activity. Tracks the execution sequence, not the structural hierarchy. |
| 38 | +- **`SchedulingWorkflowInstanceId`**: The workflow instance ID of the activity that invoked this activity's workflow. Set when crossing workflow boundaries (e.g., via `ExecuteWorkflow` or `DispatchWorkflow`). |
| 39 | + |
| 40 | +Update [`WorkflowExecutionContext.CreateActivityExecutionContextAsync`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs) to accept and store these fields from `ActivityInvocationOptions`. |
| 41 | + |
| 42 | +Add corresponding fields to [`ActivityExecutionRecord`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecord.cs): |
| 43 | +- `SchedulingActivityExecutionId` (nullable `string`) |
| 44 | +- `SchedulingActivityId` (nullable `string`) |
| 45 | +- `SchedulingWorkflowInstanceId` (nullable `string`) |
| 46 | +- `CallStackDepth` (nullable `int`) |
| 47 | + |
| 48 | +Update [`DefaultActivityExecutionMapper.MapAsync`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs) to populate these fields from the execution context. Calculate `CallStackDepth` by traversing the `SchedulingActivityExecutionId` chain until reaching null. |
| 49 | + |
| 50 | +### 3. Add ambient scheduling source to the workflow context |
| 51 | + |
| 52 | +Add an ambient scheduling source to [`WorkflowExecutionContext`]: |
| 53 | +- Fields (transient): `CurrentSchedulingActivityExecutionId`, `CurrentSchedulingWorkflowInstanceId`. |
| 54 | +- API: `IDisposable BeginSchedulingScope(string? activityExecutionId, string? workflowInstanceId)` that pushes values and restores previous values on dispose. |
| 55 | + |
| 56 | +Set ambient scope in these places: |
| 57 | +- Around owner completion callbacks (where next activities are scheduled). |
| 58 | +- Around bookmark-resume handlers (background completions resuming the workflow). |
| 59 | +- At child-workflow start (root activity creation). |
| 60 | + |
| 61 | +Modify [`WorkflowExecutionContextSchedulerStrategy.Schedule`] to set `SchedulingActivityExecutionId` and `SchedulingWorkflowInstanceId` from `ScheduleWorkOptions` if provided; otherwise fall back to the ambient `WorkflowExecutionContext` values. |
| 62 | + |
| 63 | +### 4. Update composite activities to capture scheduling activity context |
| 64 | + |
| 65 | +In [`Flowchart.Counters.cs`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs): |
| 66 | +- Update `ScheduleOutboundActivityAsync` to populate `ScheduleWorkOptions.SchedulingActivityExecutionId = completedActivityContext.Id` when a completed activity schedules its outbound activities. |
| 67 | +- Update `MaybeScheduleBackwardConnectionActivityAsync` to include `SchedulingActivityExecutionId` in the `ScheduleWorkOptions`. |
| 68 | +- Update `MaybeScheduleWaitAllActivityAsync`, `MaybeScheduleWaitAllActiveActivityAsync`, `MaybeScheduleWaitAnyActivityAsync` to pass `SchedulingActivityExecutionId` when scheduling. |
| 69 | + |
| 70 | +In [`Flowchart.Tokens.cs`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs): |
| 71 | +- Update `OnChildCompletedTokenBasedLogicAsync` to pass `SchedulingActivityExecutionId` in `ScheduleWorkOptions` when scheduling subsequent activities. |
| 72 | + |
| 73 | +Apply the same pattern to other composite activities: |
| 74 | +- `Sequence` |
| 75 | +- `ForEach` |
| 76 | +- `Parallel` |
| 77 | +- `While` |
| 78 | +- `Do` |
| 79 | +- Any other activities that schedule child activities based on completion |
| 80 | + |
| 81 | +When scheduling a child activity that was directly triggered by another activity's completion, set `SchedulingActivityExecutionId` to the completing activity's execution context ID. When omitted, the ambient scope ensures a sensible fallback. |
| 82 | + |
| 83 | +### 5. Handle cross-workflow call stack linkage (span by default) |
| 84 | + |
| 85 | +For [`ExecuteWorkflow`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Runtime/Activities/ExecuteWorkflow.cs) and [`DispatchWorkflow`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs): |
| 86 | + |
| 87 | +- Capture the calling activity's `ExecutionId` and current `WorkflowInstanceId`. |
| 88 | +- Pass them through `RunWorkflowOptions` and `DispatchWorkflowRequest` to the child workflow. |
| 89 | +- When the child workflow starts, set the first activity's: |
| 90 | + - `SchedulingActivityExecutionId` to the parent's invocation activity's execution ID. |
| 91 | + - `SchedulingWorkflowInstanceId` to the parent workflow instance ID. |
| 92 | +- Also set the ambient scope (`BeginSchedulingScope`) for the duration of child start so subsequent schedules inherit these values by default. |
| 93 | + |
| 94 | +Cross-workflow chains should be considered part of the call stack by default (span by default). |
| 95 | + |
| 96 | +### 6. Add call stack depth and create database migration |
| 97 | + |
| 98 | +Add `CallStackDepth` (nullable `int`) field to [`ActivityExecutionRecord`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecord.cs). |
| 99 | + |
| 100 | +In [`DefaultActivityExecutionMapper.MapAsync`](file:///Users/sipke/Projects/Elsa/elsa-core/main/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs): |
| 101 | +- Calculate `CallStackDepth` by traversing the source `ActivityExecutionContext.SchedulingActivityExecutionId` chain until reaching null. |
| 102 | +- Use root depth = 0 (documented convention). |
| 103 | +- Store this value in the `ActivityExecutionRecord`. |
| 104 | + |
| 105 | +Create EF Core migration adding indexed columns to `ActivityExecutionRecord` table: |
| 106 | +- `SchedulingActivityExecutionId` (indexed, nullable) |
| 107 | +- `SchedulingActivityId` (indexed, nullable) |
| 108 | +- `SchedulingWorkflowInstanceId` (indexed, nullable) |
| 109 | +- `CallStackDepth` (indexed, nullable) |
| 110 | + |
| 111 | +The `CallStackDepth` index enables efficient filtering by execution depth without reconstructing the full chain (e.g., "show me all activities at depth > 5"). |
| 112 | + |
| 113 | +### 7. Implement call stack query and reconstruction APIs |
| 114 | + |
| 115 | +Implement `IActivityExecutionStore.GetExecutionChainAsync(string activityExecutionId, bool includeCrossWorkflowChain = true, int? skip = null, int? take = null)` that: |
| 116 | +- Recursively queries `SchedulingActivityExecutionId` until reaching root (null). |
| 117 | +- Follows `SchedulingWorkflowInstanceId` across workflow boundaries by default (span by default). Optionally allow disabling cross-workflow span via parameter. |
| 118 | +- Supports pagination via `skip` and `take` parameters to handle deep call stacks efficiently. |
| 119 | +- Returns a paginated result containing: |
| 120 | + - `Items`: List of execution records (ordered from root to current activity, or subset if paginated) |
| 121 | + - `TotalCount`: Total number of items in the full chain |
| 122 | + - `Skip`: The skip value used |
| 123 | + - `Take`: The take value used |
| 124 | +- When pagination is not specified (`skip` and `take` are null), returns the full chain. |
| 125 | + |
| 126 | +Add extension methods: |
| 127 | +- **`ActivityExecutionContext.GetExecutionChain(int? skip = null, int? take = null)`**: Reconstruct the runtime call stack by traversing `SchedulingActivityExecutionId`, returning a paginated result from root to current activity. |
| 128 | +- **`ActivityExecutionRecord.GetExecutionChainAsync(IActivityExecutionStore, bool includeCrossWorkflowChain = true, int? skip = null, int? take = null)`**: Reconstruct persisted call stacks by querying the store with pagination support. |
| 129 | + |
| 130 | +Both methods should return results ordered from root to current activity, with pagination applied after ordering. |
| 131 | + |
| 132 | +Add REST API endpoint: |
| 133 | +- **`GET /api/workflow-instances/{workflowInstanceId}/activity-executions/{activityExecutionId}/call-chain`** |
| 134 | + - Query parameters: |
| 135 | + - `includeCrossWorkflowChain` (bool, default: true): Include parent workflow activities across workflow boundaries |
| 136 | + - `skip` (int?, optional): Number of items to skip (for pagination) |
| 137 | + - `take` (int?, optional): Number of items to return (for pagination, recommended max: 100) |
| 138 | + - Response: |
| 139 | + - `items`: Array of activity execution records |
| 140 | + - `totalCount`: Total number of items in the full chain |
| 141 | + - `skip`: The skip value used |
| 142 | + - `take`: The take value used (or null if full chain returned) |
| 143 | + - This enables UI to implement paginated/lazy loading for deep call stacks. |
| 144 | + |
| 145 | +## Further Considerations |
| 146 | + |
| 147 | +### 1. Structural vs temporal hierarchy documentation |
| 148 | + |
| 149 | +`ParentActivityExecutionContext` represents the structural container (e.g., Flowchart contains all its children), while `SchedulingActivityExecutionId` tracks the temporal execution predecessor (e.g., Activity B completed and directly triggered Activity C). |
| 150 | + |
| 151 | +These are orthogonal relationships: |
| 152 | +- A Flowchart can own many children (structural), but only a predecessor directly triggers the next (temporal). |
| 153 | +- When Activity B completes, it schedules the next child, establishing a temporal link via `SchedulingActivityExecutionId`. |
| 154 | + |
| 155 | +All XML comments for these fields should explicitly clarify this distinction to prevent developer confusion and misuse. |
| 156 | + |
| 157 | +### 2. Ambient scope guardrails |
| 158 | + |
| 159 | +- The ambient scope must be short-lived and always disposed via `using`/`finally` to avoid leakage between unrelated scheduling operations. |
| 160 | +- The scheduler should prefer explicit `ScheduleWorkOptions.SchedulingActivityExecutionId`/`SchedulingWorkflowInstanceId` and only fall back to ambient when not provided. |
| 161 | +- Document that the ambient exists to reduce boilerplate and should not be relied upon when explicit causal context is readily available. |
| 162 | + |
| 163 | +### 3. Cross-workflow boundary reconstruction (default span) |
| 164 | + |
| 165 | +`SchedulingWorkflowInstanceId` enables reconstructing call stacks that span multiple workflow instances: |
| 166 | +- Parent Workflow (Instance A) → ExecuteWorkflow activity (in Instance A) → Child Workflow (Instance B) → failing activity (in Instance B). |
| 167 | + |
| 168 | +Since span is the default, cross-instance traversal should occur unless explicitly disabled. |
| 169 | + |
| 170 | +### 4. Call stack depth optimization trade-offs |
| 171 | + |
| 172 | +Storing `CallStackDepth` trades a small amount of storage for simpler, faster queries and analytics: |
| 173 | + |
| 174 | +**Benefits:** |
| 175 | +- Efficient filtering by depth ranges (e.g., "depth > 5"). |
| 176 | +- Early termination in chain reconstruction. |
| 177 | +- Index-based analytics queries. |
| 178 | +- Lower storage than persisting full chains. |
| 179 | + |
| 180 | +**Drawbacks:** |
| 181 | +- `CallStackDepth` becomes stale if parent records are deleted or altered post-hoc. Prefer immutable execution records. |
| 182 | +- Document that `CallStackDepth` is an optimization hint for querying, not an authoritative source if retention policies prune ancestors. |
| 183 | + |
| 184 | +### 5. Testing matrix |
| 185 | + |
| 186 | +- Sequential flow: A → B → C (explicit predecessor set, ambient unused). |
| 187 | +- Parallel fan-out: A schedules B and C (both record A as predecessor; ambient vs explicit). |
| 188 | +- Nested composites: Multiple owners scheduling into the same queue. |
| 189 | +- Background resume: Bookmark-based resumes interleaving with other work; ambient set during resume. |
| 190 | +- Cross-workflow: Execute/Dispatch child workflow; default spanning chain. |
| 191 | +- Deduplication scenarios: `PreventDuplicateScheduling` and re-scheduling. |
| 192 | +- Persistence/round-trips: Background/persisted scheduled activities using `ScheduledActivityOptions`. |
| 193 | +- Deep call stacks: Test pagination with chains deeper than 100 activities. |
| 194 | +- Cross-workflow pagination: Ensure pagination works correctly when spanning workflow boundaries. |
| 195 | + |
| 196 | +### 6. Performance and pagination considerations |
| 197 | + |
| 198 | +**Deep call stack handling:** |
| 199 | +- For very deep call stacks (e.g., recursive workflows or long-running sequential processes), retrieving the entire chain in a single query can be expensive. |
| 200 | +- Pagination (`skip`/`take`) enables efficient loading in the UI with incremental/lazy loading patterns. |
| 201 | +- Recommend default `take` of 50-100 items per page for REST API calls. |
| 202 | +- The `CallStackDepth` field enables quick assessment of chain depth before deciding whether to paginate. |
| 203 | + |
| 204 | +**Query optimization strategies:** |
| 205 | +- Use indexed lookups on `SchedulingActivityExecutionId` to traverse the chain efficiently. |
| 206 | +- Consider caching strategies for frequently accessed chains (e.g., recently failed activities). |
| 207 | +- For cross-workflow queries, implement efficient join strategies or batched lookups to minimize round-trips. |
| 208 | +- Document that pagination is "forward-only" (skip/take from root toward current) to align with typical debugging workflows (start at root, drill down). |
| 209 | + |
| 210 | +**REST API rate limiting:** |
| 211 | +- Consider rate limiting on the call chain endpoint if it becomes a performance bottleneck. |
| 212 | +- Monitor query performance and adjust default pagination sizes based on observed data. |
0 commit comments