3.Elsa源码探索-调度中心-WorkflowRuntime
一、这个模块是做什么的?
如果说 Elsa.Workflows.Core 是发动机,Elsa.Workflows.Runtime 就是"调度中心"。它负责回答两个问题:
- 外部信号来了,应该启动哪个工作流?(触发器索引)
- 外部信号来了,应该唤醒哪个挂起的工作流?(书签匹配与恢复)
Core 只管"工作流怎么跑",Runtime 管"工作流什么时候跑、跑哪一个"。两者分工明确:Runtime 负责找到目标,Core 负责执行。
用一句话概括:Elsa.Workflows.Runtime 是连接"外部世界"与"执行引擎"的路由层。
二、核心概念:Stimulus、Trigger、Bookmark 的关系
理解本模块之前先搞清楚三个概念。
Stimulus(刺激信号):外部世界发来的信号,比如"收到一个 HTTP POST 请求"。每个刺激信号由活动类型名 + 信号参数的哈希值唯一标识。
Trigger(触发器):数据库里的一条记录,含义是"工作流 X 的活动 Y 可以被哈希值为 Z 的信号触发启动"。触发器在工作流发布时由 TriggerIndexer 写入数据库,代表"这个工作流准备好接受某类信号来启动自己"。
Bookmark(书签):数据库里的另一条记录,含义是"工作流实例 A 的活动 B 正在等待哈希值为 Z 的信号来恢复执行"。书签在工作流运行挂起时由 BookmarkPersister 写入,代表"这个工作流实例在等某类信号"。
一条信号进来,Runtime 用同一个哈希值同时查 Trigger 表(启动新实例)和 Bookmark 表(恢复已有实例)。
三、触发器索引:工作流发布时注册"启动条件"
当一个工作流发布,IndexTriggers 通知处理器(Handlers/IndexTriggers.cs)立即触发:
// Handlers/IndexTriggers.cs
internal class IndexTriggers(ITriggerIndexer triggerIndexer) :
INotificationHandler<WorkflowDefinitionPublished>,
INotificationHandler<WorkflowDefinitionRetracted>
{
public async Task HandleAsync(WorkflowDefinitionPublished notification, CancellationToken cancellationToken)
=> await triggerIndexer.IndexTriggersAsync(notification.WorkflowDefinition, cancellationToken);
public async Task HandleAsync(WorkflowDefinitionRetracted notification, CancellationToken cancellationToken)
=> await triggerIndexer.IndexTriggersAsync(notification.WorkflowDefinition, cancellationToken);
}
IndexTriggers 同时监听 WorkflowDefinitionPublished(发布后写入触发器)和 WorkflowDefinitionRetracted(撤回后清空触发器),通过同一套差量逻辑处理两种场景。
TriggerIndexer.IndexTriggersAsync()(Services/TriggerIndexer.cs 第100行)是触发器索引的核心:
// Services/TriggerIndexer.cs 第100行
public async Task<IndexedWorkflowTriggers> IndexTriggersAsync(Workflow workflow, CancellationToken cancellationToken = default)
{
// 分布式锁,防止并发索引同一工作流出现并发冲突
var lockResource = $"trigger-indexer:{workflow.Identity.DefinitionId}";
await using (await _distributedLockProvider.AcquireLockAsync(lockResource, _lockingOptions.LockAcquisitionTimeout, cancellationToken))
{
return await IndexTriggersInternalAsync(workflow, cancellationToken);
}
}
内部逻辑是差量更新(Services/TriggerIndexer.cs 第110行):
// Services/TriggerIndexer.cs 第110行
private async Task<IndexedWorkflowTriggers> IndexTriggersInternalAsync(Workflow workflow, ...)
{
// 1. 从数据库取当前已存的触发器
var currentTriggers = await GetCurrentTriggersAsync(workflow.Identity.DefinitionId, ...);
// 2. 遍历工作流活动树,找出所有实现了 ITrigger 且 CanStartWorkflow=true 的活动
var newTriggers = workflow.Publication.IsPublished
? await GetTriggersInternalAsync(workflow, cancellationToken).ToListAsync()
: new(0); // 未发布(撤回)则清空触发器
// 3. Diff:找出新增和删除的
var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer());
// 4. 写库(删除旧的,插入新的)
await _triggerStore.ReplaceAsync(diff.Removed, diff.Added, cancellationToken);
await _notificationSender.SendAsync(new WorkflowTriggersIndexed(indexedWorkflow), ...);
}
触发器如何生成(Services/TriggerIndexer.cs 第202行):遍历工作流的活动树,筛选出所有满足 GetCanStartWorkflow() = true 且实现了 ITrigger 接口的活动。对每个触发活动,调用 trigger.GetTriggerPayloadsAsync() 获取所有可能的触发参数,为每组参数生成一条 StoredTrigger:
// Services/TriggerIndexer.cs 第202行
var triggers = triggerData.Select(payload => new StoredTrigger
{
Id = _identityGenerator.GenerateId(), // string 类型
WorkflowDefinitionId = workflow.Identity.DefinitionId,
WorkflowDefinitionVersionId = workflow.Identity.Id,
Name = triggerName,
ActivityId = trigger.Id,
Hash = _hasher.Hash(triggerName, payload), // 信号哈希,匹配时用
Payload = payload
});
四、信号入口:StimulusSender
StimulusSender(Services/StimulusSender.cs)是外部信号进入系统的统一入口:
// Services/StimulusSender.cs 第26行
public async Task<SendStimulusResult> SendAsync(string stimulusHash, StimulusMetadata? metadata = null, CancellationToken cancellationToken = default)
{
var responses = new List<RunWorkflowInstanceResponse>();
// 路径一:没有指定具体实例 → 尝试启动新工作流
if (metadata == null || (metadata.WorkflowInstanceId == null && metadata.BookmarkId == null && metadata.ActivityInstanceId == null))
{
var triggered = await TriggerNewWorkflowsAsync(stimulusHash, metadata, cancellationToken);
responses.AddRange(triggered);
}
// 路径二:尝试恢复已有工作流
var resumed = await ResumeExistingWorkflowsAsync(stimulusHash, metadata, cancellationToken);
responses.AddRange(resumed);
return new(responses);
}
两条路径同时走:一个信号既可以启动新实例(Trigger 匹配),也可以唤醒已有实例(Bookmark 匹配)。
五、路径一:触发启动新工作流
TriggerNewWorkflowsAsync()(第41行)用信号哈希查 Trigger 表,找到匹配的工作流定义,然后调用 TriggerInvoker.InvokeAsync() 启动:
// Services/StimulusSender.cs 第41行
private async Task<ICollection<RunWorkflowInstanceResponse>> TriggerNewWorkflowsAsync(string stimulusHash, ...)
{
var triggerBoundWorkflows = await triggerBoundWorkflowService.FindManyAsync(stimulusHash, cancellationToken).ToList();
foreach (var triggerBoundWorkflow in triggerBoundWorkflows)
{
foreach (var trigger in triggerBoundWorkflow.Triggers)
{
var response = await triggerInvoker.InvokeAsync(new InvokeTriggerRequest
{
Workflow = workflow,
ActivityId = trigger.ActivityId, // 指定从哪个触发活动开始
CorrelationId = correlationId,
Input = input,
...
}, cancellationToken);
if (response.CannotStart)
continue; // 激活策略不允许启动(如单例模式已有实例)
responses.Add(response.ToRunWorkflowInstanceResponse());
}
}
}
TriggerBoundWorkflowService.FindManyAsync()(Services/TriggerBoundWorkflowService.cs)先用 WorkflowMatcher 找到所有匹配的 StoredTrigger,再把它们按 WorkflowDefinitionVersionId 分组,然后为每个版本物化出 WorkflowGraph,包装成 TriggerBoundWorkflow 返回(图 + 触发器列表的组合体)。
触发器最终调用 DefaultWorkflowStarter.StartWorkflowAsync()(Services/DefaultWorkflowStarter.cs),先用激活策略(IWorkflowActivationStrategyEvaluator)判断是否允许启动,通过后创建 LocalWorkflowClient 并调用 CreateAndRunInstanceAsync()。
六、路径二:恢复挂起的工作流
ResumeExistingWorkflowsAsync()(第82行)用信号哈希查 Bookmark 表:
// Services/StimulusSender.cs 第82行
private async Task<ICollection<RunWorkflowInstanceResponse>> ResumeExistingWorkflowsAsync(string stimulusHash, ...)
{
var bookmarkFilter = new BookmarkFilter { Hash = stimulusHash, ... };
var responses = (await workflowResumer.ResumeAsync(bookmarkFilter, ..., cancellationToken)).ToList();
if (responses.Count > 0)
return responses;
// 如果当前没有匹配的书签(工作流可能还没到达等待点),
// 把这个信号放入书签队列,稍后重试
await bookmarkQueue.EnqueueAsync(new NewBookmarkQueueItem { StimulusHash = stimulusHash, ... }, ...);
return responses;
}
如果当前没有匹配的书签(信号比工作流更早到达),信号会被存入"书签队列"(BookmarkQueue)等待。
WorkflowResumer.ResumeAsync()(Services/WorkflowResumer.cs)是恢复的核心:
// Services/WorkflowResumer.cs
public async Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync(BookmarkFilter filter, ...)
{
var bookmarks = (await bookmarkStore.FindManyAsync(filter, cancellationToken)).ToList();
if (bookmarks.Count == 0) return [];
foreach (var bookmark in bookmarks)
{
var workflowClient = await workflowRuntime.CreateClientAsync(bookmark.WorkflowInstanceId, ...);
var response = await workflowClient.RunInstanceAsync(new RunWorkflowInstanceRequest
{
BookmarkId = bookmark.Id, // 传入书签 ID,WorkflowRunner 用它找到恢复点
Input = options?.Input,
...
}, cancellationToken);
responses.Add(response);
}
return responses;
}
七、WorkflowRuntime 与 WorkflowClient:执行的最后一公里
LocalWorkflowRuntime(Services/LocalWorkflowRuntime.cs)是单节点部署的运行时实现:
// Services/LocalWorkflowRuntime.cs
public ValueTask<IWorkflowClient> CreateClientAsync(string? workflowInstanceId, ...)
{
workflowInstanceId ??= _identityGenerator.GenerateId();
var client = (IWorkflowClient)ActivatorUtilities.CreateInstance(
_serviceProvider, typeof(LocalWorkflowClient), workflowInstanceId);
return new(client);
}
每次执行都为目标实例创建一个 LocalWorkflowClient,Client 是一个绑定了具体实例 ID 的操作代理。
LocalWorkflowClient.RunInstanceAsync()(Services/LocalWorkflowClient.cs)是实际执行的汇合点:它检查实例状态,物化工作流图,然后调用 WorkflowRunner.RunAsync(workflowGraph, workflowState, options)——至此接力棒交还给 Elsa.Workflows.Core 执行引擎。
八、书签队列:处理"信号早于书签"的竞态
有一种竞态场景:工作流刚创建还没运行到等待节点,外部信号却已经到来,
Bookmark尚未写入数据库,直接匹配会找不到任何结果。StimulusSender的解法是:匹配失败时把信号塞进书签队列(BookmarkQueue),由后台工作者定期重试。整个队列机制由三个类组成:
BookmarkQueueSignaler:轻量级信号
BookmarkQueueSignaler(Services/BookmarkQueueSignaler.cs)基于 System.Threading.Channels 实现,容量为 1 的有界信道,只传递"有工作要做"这一信号:
// Services/BookmarkQueueSignaler.cs
_channel = Channel.CreateBounded<object?>(new BoundedChannelOptions(1)
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
public Task TriggerAsync(CancellationToken cancellationToken)
{
_channel.Writer.TryWrite(null); //channel 容量为 1,TryWrite 成功一次就够了,后面重复触发直接丢掉,避免大量重复对象堆积
return Task.CompletedTask;
}
这里如果让我来设计,大概率会把每条书签都扔 channel 里,生产者生产一条,消费者就处理一条。而 elsa 的巧妙设计是将其视为一个“门铃”,当有需要处理的数据产生时,并不是笨笨的把要处理的数据传递过去,而是传递”要干活了”这个信号。这样做的好处是可以减少无效入队,把消费者做成了分批处理,当生产者疯狂产生消息时,会因为通道的容量只有一,达到天然降噪的效果。
BookmarkQueueWorker:后台监听循环
BookmarkQueueWorker(Services/BookmarkQueueWorker.cs)在后台持续等待信号
// Services/BookmarkQueueWorker.cs 第46行
private async Task AwaitSignalAsync()
{
while (!_cts.IsCancellationRequested)
{
await _signaler.AwaitAsync(_cts.Token); // 阻塞等待信号
await _rateLimitedProcessAsync.InvokeAsync(_cts.Token); // 500ms 触发一次
}
}
触发这个工作者的来源有多个(Handlers/SignalBookmarkQueueWorker.cs):收到 BookmarkSaved、WorkflowBookmarksIndexed、WorkflowInstanceSaved 等通知时,都会调用 signaler.TriggerAsync() 唤醒工作者。
BookmarkQueueProcessor:实际消费逻辑
BookmarkQueueProcessor(Services/BookmarkQueueProcessor.cs)分页从队列表取出待处理项,对每一项调用 WorkflowResumer.ResumeAsync() 重试恢复:
// Services/BookmarkQueueProcessor.cs
private async Task ProcessItemAsync(BookmarkQueueItem item, ...)
{
var responses = (await workflowResumer.ResumeAsync(filter, options, cancellationToken)).ToList();
if (responses.Count > 0)
await store.DeleteAsync(new BookmarkQueueFilter { Id = item.Id }, ...); // 成功则从队列删除
// 失败则保留,等下次触发再重试
}
九、书签的持久化:BookmarkPersister
工作流执行完一轮后,BookmarkPersister(Services/BookmarkPersister.cs)负责把内存中的书签变化同步到数据库:
// Services/BookmarkPersister.cs
public async Task PersistBookmarksAsync(UpdateBookmarksRequest request)
{
await bookmarkUpdater.UpdateBookmarksAsync(request); // 差量写库(删旧增新)
await localEventBus.PublishAsync(new WorkflowBookmarksIndexed(...)); // 触发书签队列工作者
await localEventBus.PublishAsync(new WorkflowBookmarksPersisted(...));
}
WorkflowBookmarksIndexed 通知发出后,SignalBookmarkQueueWorker 消费它,调用 signaler.TriggerAsync() 唤醒后台工作者。这就完成了"书签写入 → 唤醒队列工作者 → 重试之前未匹配的信号"的闭环。
工作流实例删除时,DeleteBookmarks(Handlers/DeleteBookmarks.cs)负责级联删除该实例的所有书签。
十、WorkflowHost:对外暴露的工作流宿主抽象
Elsa 原版还提供了 WorkflowHost(Services/WorkflowHost.cs)和 WorkflowInvoker(Services/WorkflowInvoker.cs)两个更高级的抽象,供外部调用者以更简洁的方式触发和管理工作流,无需直接操作低级的 StimulusSender 和 WorkflowRunner。
WorkflowHost 封装了"获取客户端 → 创建/运行实例"的完整流程;WorkflowInvoker 进一步简化,直接根据工作流定义 ID 或工作流类型发起调用。
十一、整体流程总结
外部信号
↓
StimulusSender.SendAsync(stimulusHash)
├─ TriggerNewWorkflowsAsync()
│ └─ TriggerBoundWorkflowService.FindManyAsync(stimulusHash)
│ └─ WorkflowMatcher.FindTriggersAsync() → 查 StoredTrigger 表
│ └─ TriggerInvoker.InvokeAsync()
│ └─ DefaultWorkflowStarter.StartWorkflowAsync()
│ └─ WorkflowActivationStrategyEvaluator.CanStartWorkflow()
│ └─ LocalWorkflowRuntime.CreateClientAsync() → LocalWorkflowClient
│ └─ CreateAndRunInstanceAsync() → WorkflowRunner.RunAsync() [全新启动]
│
└─ ResumeExistingWorkflowsAsync()
├─ WorkflowResumer.ResumeAsync(BookmarkFilter)
│ └─ 查 Bookmark 表,找到匹配书签
│ └─ LocalWorkflowRuntime.CreateClientAsync() → LocalWorkflowClient
│ └─ RunInstanceAsync(BookmarkId) → WorkflowRunner.RunAsync() [断点恢复]
│
└─ 未找到书签 → BookmarkQueue.EnqueueAsync() [信号入队等待]
↓
工作流执行完一轮 → BookmarkPersister.PersistBookmarksAsync()
→ WorkflowBookmarksIndexed 通知
→ SignalBookmarkQueueWorker → signaler.TriggerAsync()
→ BookmarkQueueWorker 唤醒 → BookmarkQueueProcessor.ProcessAsync()
→ WorkflowResumer.ResumeAsync() [重试恢复]
整个模块的设计核心是最终一致性:信号和书签不要求严格同步到达,通过队列机制保证"稍晚到达的信号依然能找到它的工作流"。
下一篇:Elsa.Workflows.Runtime.Distributed — 分布式运行时详解

浙公网安备 33010602011771号