3.Elsa源码探索-调度中心-WorkflowRuntime

一、这个模块是做什么的?

如果说 Elsa.Workflows.Core 是发动机,Elsa.Workflows.Runtime 就是"调度中心"。它负责回答两个问题:

  1. 外部信号来了,应该启动哪个工作流?(触发器索引)
  2. 外部信号来了,应该唤醒哪个挂起的工作流?(书签匹配与恢复)

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

StimulusSenderServices/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:执行的最后一公里

LocalWorkflowRuntimeServices/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:轻量级信号

BookmarkQueueSignalerServices/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:后台监听循环

BookmarkQueueWorkerServices/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):收到 BookmarkSavedWorkflowBookmarksIndexedWorkflowInstanceSaved 等通知时,都会调用 signaler.TriggerAsync() 唤醒工作者。

BookmarkQueueProcessor:实际消费逻辑

BookmarkQueueProcessorServices/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

工作流执行完一轮后,BookmarkPersisterServices/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() 唤醒后台工作者。这就完成了"书签写入 → 唤醒队列工作者 → 重试之前未匹配的信号"的闭环。

工作流实例删除时,DeleteBookmarksHandlers/DeleteBookmarks.cs)负责级联删除该实例的所有书签。

十、WorkflowHost:对外暴露的工作流宿主抽象

Elsa 原版还提供了 WorkflowHostServices/WorkflowHost.cs)和 WorkflowInvokerServices/WorkflowInvoker.cs)两个更高级的抽象,供外部调用者以更简洁的方式触发和管理工作流,无需直接操作低级的 StimulusSenderWorkflowRunner

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 — 分布式运行时详解

posted @ 2026-04-25 09:11  叨奈特挖井人  阅读(60)  评论(0)    收藏  举报