feat: process workflow timers with leases
This commit is contained in:
parent
2eb6371c94
commit
4aed8d9fa2
@ -0,0 +1,13 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Workflow.Application.Scheduler.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 原子领取并处理一批到期工作流 Timer 的命令。
|
||||
/// </summary>
|
||||
public sealed record ProcessDueWorkflowTimersCommand(
|
||||
string LeaseOwner,
|
||||
int BatchSize,
|
||||
TimeSpan LeaseDuration,
|
||||
int MaxAttempts,
|
||||
TimeSpan RetryDelay) : IRequest<int>;
|
||||
@ -0,0 +1,240 @@
|
||||
using System.Data.Common;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Workflow.Application.Engine;
|
||||
using Workflow.Application.Scheduler.Commands;
|
||||
using Workflow.Domain.Entities;
|
||||
using Workflow.Domain.Enums;
|
||||
using Workflow.Infrastructure.Persistence;
|
||||
|
||||
namespace Workflow.Application.Scheduler.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 使用 PostgreSQL 租约原子领取到期 Timer,并在事务内完成 token 推进。
|
||||
/// </summary>
|
||||
public sealed class ProcessDueWorkflowTimersCommandHandler(
|
||||
WorkflowDbContext db,
|
||||
ProcessEngine engine,
|
||||
ILogger<ProcessDueWorkflowTimersCommandHandler> logger)
|
||||
: IRequestHandler<ProcessDueWorkflowTimersCommand, int>
|
||||
{
|
||||
public async Task<int> Handle(
|
||||
ProcessDueWorkflowTimersCommand request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var claimedIds = await ClaimAsync(request, ct);
|
||||
foreach (var jobId in claimedIds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await ProcessClaimedAsync(jobId, request.LeaseOwner, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "工作流 Timer 任务 {JobId} 处理失败", jobId);
|
||||
await HandleFailureAsync(jobId, request, ex, ct);
|
||||
}
|
||||
}
|
||||
|
||||
return claimedIds.Count;
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> ClaimAsync(
|
||||
ProcessDueWorkflowTimersCommand request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var leaseExpiresAt = now.Add(request.LeaseDuration);
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
UPDATE b_workflow_timer_jobs
|
||||
SET status = {(int)WorkflowTimerJobStatus.Failed},
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
completed_at = {now},
|
||||
last_error = {"任务租约过期且已达到最大重试次数"},
|
||||
updated_by = {"system"},
|
||||
updated_at = {now}
|
||||
WHERE status = {(int)WorkflowTimerJobStatus.Processing}
|
||||
AND lease_expires_at <= {now}
|
||||
AND attempt_count >= {request.MaxAttempts}
|
||||
""", ct);
|
||||
|
||||
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||
command.Transaction = transaction.GetDbTransaction();
|
||||
command.CommandText = """
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM b_workflow_timer_jobs
|
||||
WHERE (
|
||||
(status = @pending AND due_at <= @now)
|
||||
OR (status = @processing AND lease_expires_at <= @now)
|
||||
)
|
||||
AND attempt_count < @max_attempts
|
||||
ORDER BY due_at, created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @batch_size
|
||||
)
|
||||
UPDATE b_workflow_timer_jobs AS job
|
||||
SET status = @processing,
|
||||
lease_owner = @lease_owner,
|
||||
lease_expires_at = @lease_expires_at,
|
||||
attempt_count = job.attempt_count + 1,
|
||||
last_error = NULL,
|
||||
updated_by = 'system',
|
||||
updated_at = @now
|
||||
FROM candidates
|
||||
WHERE job.id = candidates.id
|
||||
RETURNING job.id
|
||||
""";
|
||||
AddParameter(command, "pending", (int)WorkflowTimerJobStatus.Pending);
|
||||
AddParameter(command, "processing", (int)WorkflowTimerJobStatus.Processing);
|
||||
AddParameter(command, "now", now);
|
||||
AddParameter(command, "max_attempts", request.MaxAttempts);
|
||||
AddParameter(command, "batch_size", request.BatchSize);
|
||||
AddParameter(command, "lease_owner", request.LeaseOwner);
|
||||
AddParameter(command, "lease_expires_at", leaseExpiresAt);
|
||||
|
||||
var claimedIds = new List<Guid>();
|
||||
await using (var reader = await command.ExecuteReaderAsync(ct))
|
||||
{
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
claimedIds.Add(reader.GetGuid(0));
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
return claimedIds;
|
||||
}
|
||||
|
||||
private async Task ProcessClaimedAsync(
|
||||
Guid jobId,
|
||||
string leaseOwner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(ct);
|
||||
var job = await db.WorkflowTimerJobs
|
||||
.FromSqlInterpolated($"SELECT * FROM b_workflow_timer_jobs WHERE id = {jobId} FOR UPDATE")
|
||||
.SingleOrDefaultAsync(ct);
|
||||
if (job is null
|
||||
|| job.Status != WorkflowTimerJobStatus.Processing
|
||||
|| job.LeaseOwner != leaseOwner)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var instance = await db.WorkflowInstances.FindAsync([job.InstanceId], ct);
|
||||
var token = await db.WorkflowTokens.FindAsync([job.TokenId], ct);
|
||||
if (instance is null
|
||||
|| token is null
|
||||
|| instance.Status != InstanceStatus.Running
|
||||
|| token.Status != TokenStatus.Active)
|
||||
{
|
||||
CompleteJob(job, WorkflowTimerJobStatus.Cancelled);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await engine.EnsureGraphLoadedForAsync(instance);
|
||||
var edge = (await engine.GetOutgoingEdgesPublicAsync(job.NodeId)).FirstOrDefault();
|
||||
token.Status = TokenStatus.Consumed;
|
||||
token.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
WorkflowToken? downstreamToken = null;
|
||||
if (edge is not null)
|
||||
{
|
||||
downstreamToken = new WorkflowToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = instance.Id,
|
||||
NodeId = edge.TargetNodeId,
|
||||
Status = TokenStatus.Active,
|
||||
};
|
||||
db.WorkflowTokens.Add(downstreamToken);
|
||||
}
|
||||
|
||||
CompleteJob(job, WorkflowTimerJobStatus.Completed);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (downstreamToken is not null)
|
||||
{
|
||||
var targetNode = await engine.FindNodePublicAsync(downstreamToken.NodeId);
|
||||
if (targetNode is not null)
|
||||
{
|
||||
await engine.ProcessNodeAsync(instance, downstreamToken, targetNode);
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
private async Task HandleFailureAsync(
|
||||
Guid jobId,
|
||||
ProcessDueWorkflowTimersCommand request,
|
||||
Exception exception,
|
||||
CancellationToken ct)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(ct);
|
||||
var job = await db.WorkflowTimerJobs
|
||||
.FromSqlInterpolated($"SELECT * FROM b_workflow_timer_jobs WHERE id = {jobId} FOR UPDATE")
|
||||
.SingleOrDefaultAsync(ct);
|
||||
if (job is null
|
||||
|| job.Status != WorkflowTimerJobStatus.Processing
|
||||
|| job.LeaseOwner != request.LeaseOwner)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
job.LastError = Truncate(exception.Message, 2000);
|
||||
job.LeaseOwner = null;
|
||||
job.LeaseExpiresAt = null;
|
||||
if (job.AttemptCount >= request.MaxAttempts)
|
||||
{
|
||||
job.Status = WorkflowTimerJobStatus.Failed;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.Status = WorkflowTimerJobStatus.Pending;
|
||||
job.DueAt = DateTime.UtcNow.Add(request.RetryDelay);
|
||||
job.CompletedAt = null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
private static void CompleteJob(
|
||||
WorkflowTimerJob job,
|
||||
WorkflowTimerJobStatus status)
|
||||
{
|
||||
job.Status = status;
|
||||
job.LeaseOwner = null;
|
||||
job.LeaseExpiresAt = null;
|
||||
job.LastError = null;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static void AddParameter(DbCommand command, string name, object value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Workflow.Tests.Reliability;
|
||||
|
||||
/// <summary>
|
||||
/// 串行执行工作流 PostgreSQL 可靠性测试,避免共享测试服务发生数据库级竞争。
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class PostgresWorkflowCollection : ICollectionFixture<PostgresWorkflowFixture>
|
||||
{
|
||||
/// <summary>集合名称。</summary>
|
||||
public const string Name = "workflow-postgres";
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Workflow.Tests.Reliability;
|
||||
|
||||
/// <summary>
|
||||
/// 为工作流可靠性测试创建并销毁独立 PostgreSQL 数据库。
|
||||
/// </summary>
|
||||
public sealed class PostgresWorkflowFixture : IAsyncLifetime
|
||||
{
|
||||
private const string DefaultConnection =
|
||||
"Host=127.0.0.1;Port=55432;Username=buildflow;Password=buildflow;Database=postgres";
|
||||
|
||||
private string _databaseName = default!;
|
||||
private string _adminConnectionString = default!;
|
||||
|
||||
/// <summary>当前测试数据库连接串。</summary>
|
||||
public string ConnectionString { get; private set; } = default!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("BUILDFLOW_WORKFLOW_TEST_CONNECTION")
|
||||
?? DefaultConnection;
|
||||
var adminBuilder = new NpgsqlConnectionStringBuilder(configured)
|
||||
{
|
||||
Database = "postgres",
|
||||
Pooling = false,
|
||||
};
|
||||
_adminConnectionString = adminBuilder.ConnectionString;
|
||||
_databaseName = $"workflow_reliability_{Guid.NewGuid():N}";
|
||||
|
||||
await using (var connection = new NpgsqlConnection(_adminConnectionString))
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"CREATE DATABASE \"{_databaseName}\"";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var databaseBuilder = new NpgsqlConnectionStringBuilder(configured)
|
||||
{
|
||||
Database = _databaseName,
|
||||
Pooling = true,
|
||||
};
|
||||
ConnectionString = databaseBuilder.ConnectionString;
|
||||
|
||||
await using var db = CreateDbContext();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
/// <summary>创建指向独立测试数据库的 WorkflowDbContext。</summary>
|
||||
public WorkflowDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
|
||||
.UseNpgsql(ConnectionString)
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
var db = new WorkflowDbContext(options);
|
||||
db.SavingChanges += AuditStub.OnSave;
|
||||
return db;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
await using var connection = new NpgsqlConnection(_adminConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using (var terminate = connection.CreateCommand())
|
||||
{
|
||||
terminate.CommandText =
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @database AND pid <> pg_backend_pid()";
|
||||
terminate.Parameters.AddWithValue("database", _databaseName);
|
||||
await terminate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using var drop = connection.CreateCommand();
|
||||
drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\"";
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Workflow.Application.Engine;
|
||||
using Workflow.Application.Scheduler.Commands;
|
||||
using Workflow.Application.Scheduler.Handlers;
|
||||
using Workflow.Domain.Entities;
|
||||
using Workflow.Domain.Enums;
|
||||
using Workflow.Domain.Expressions;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Workflow.Tests.Reliability;
|
||||
|
||||
/// <summary>
|
||||
/// 工作流 Timer 的 PostgreSQL 原子领取、租约恢复和事务推进测试。
|
||||
/// </summary>
|
||||
[Collection(PostgresWorkflowCollection.Name)]
|
||||
public class WorkflowTimerProcessorTests(PostgresWorkflowFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task TwoWorkersClaimingOneDueTimerCreateOnlyOneDownstreamToken()
|
||||
{
|
||||
await ResetAsync();
|
||||
var seeded = await SeedTimerAsync(InstanceStatus.Running);
|
||||
await using var dbA = fixture.CreateDbContext();
|
||||
await using var dbB = fixture.CreateDbContext();
|
||||
var handlerA = CreateHandler(dbA);
|
||||
var handlerB = CreateHandler(dbB);
|
||||
|
||||
var results = await Task.WhenAll(
|
||||
handlerA.Handle(CreateCommand("worker-a"), CancellationToken.None),
|
||||
handlerB.Handle(CreateCommand("worker-b"), CancellationToken.None));
|
||||
|
||||
results.Sum().Should().Be(1);
|
||||
await using var verify = fixture.CreateDbContext();
|
||||
var job = await verify.WorkflowTimerJobs.SingleAsync(x => x.Id == seeded.JobId);
|
||||
job.Status.Should().Be(WorkflowTimerJobStatus.Completed);
|
||||
job.LeaseOwner.Should().BeNull();
|
||||
(await verify.WorkflowTokens.SingleAsync(x => x.Id == seeded.TokenId))
|
||||
.Status.Should().Be(TokenStatus.Consumed);
|
||||
(await verify.WorkflowTokens.CountAsync(x =>
|
||||
x.InstanceId == seeded.InstanceId
|
||||
&& x.NodeId == seeded.TargetNodeId
|
||||
&& x.Status == TokenStatus.Active)).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredProcessingLeaseCanBeRecoveredByAnotherWorker()
|
||||
{
|
||||
await ResetAsync();
|
||||
var seeded = await SeedTimerAsync(
|
||||
InstanceStatus.Running,
|
||||
WorkflowTimerJobStatus.Processing,
|
||||
"dead-worker",
|
||||
DateTime.UtcNow.AddMinutes(-1),
|
||||
attemptCount: 1);
|
||||
await using var db = fixture.CreateDbContext();
|
||||
var handler = CreateHandler(db);
|
||||
|
||||
var processed = await handler.Handle(CreateCommand("recovery-worker"), CancellationToken.None);
|
||||
|
||||
processed.Should().Be(1);
|
||||
await using var verify = fixture.CreateDbContext();
|
||||
var job = await verify.WorkflowTimerJobs.SingleAsync(x => x.Id == seeded.JobId);
|
||||
job.Status.Should().Be(WorkflowTimerJobStatus.Completed);
|
||||
job.AttemptCount.Should().Be(2);
|
||||
job.LeaseOwner.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonRunningInstanceCancelsTimerWithoutCreatingDownstreamToken()
|
||||
{
|
||||
await ResetAsync();
|
||||
var seeded = await SeedTimerAsync(InstanceStatus.Terminated);
|
||||
await using var db = fixture.CreateDbContext();
|
||||
var handler = CreateHandler(db);
|
||||
|
||||
var processed = await handler.Handle(CreateCommand("worker-a"), CancellationToken.None);
|
||||
|
||||
processed.Should().Be(1);
|
||||
await using var verify = fixture.CreateDbContext();
|
||||
var job = await verify.WorkflowTimerJobs.SingleAsync(x => x.Id == seeded.JobId);
|
||||
job.Status.Should().Be(WorkflowTimerJobStatus.Cancelled);
|
||||
job.LeaseOwner.Should().BeNull();
|
||||
(await verify.WorkflowTokens.CountAsync(x => x.NodeId == seeded.TargetNodeId)).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredLeaseAtMaxAttemptsBecomesFailed()
|
||||
{
|
||||
await ResetAsync();
|
||||
var seeded = await SeedTimerAsync(
|
||||
InstanceStatus.Running,
|
||||
WorkflowTimerJobStatus.Processing,
|
||||
"dead-worker",
|
||||
DateTime.UtcNow.AddMinutes(-1),
|
||||
attemptCount: 5);
|
||||
await using var db = fixture.CreateDbContext();
|
||||
var handler = CreateHandler(db);
|
||||
|
||||
var processed = await handler.Handle(CreateCommand("worker-a"), CancellationToken.None);
|
||||
|
||||
processed.Should().Be(0);
|
||||
await using var verify = fixture.CreateDbContext();
|
||||
var job = await verify.WorkflowTimerJobs.SingleAsync(x => x.Id == seeded.JobId);
|
||||
job.Status.Should().Be(WorkflowTimerJobStatus.Failed);
|
||||
job.LastError.Should().Contain("最大重试次数");
|
||||
job.LeaseOwner.Should().BeNull();
|
||||
(await verify.WorkflowTokens.CountAsync(x => x.NodeId == seeded.TargetNodeId)).Should().Be(0);
|
||||
}
|
||||
|
||||
private static ProcessDueWorkflowTimersCommand CreateCommand(string owner) => new(
|
||||
owner,
|
||||
BatchSize: 10,
|
||||
LeaseDuration: TimeSpan.FromMinutes(1),
|
||||
MaxAttempts: 5,
|
||||
RetryDelay: TimeSpan.FromSeconds(1));
|
||||
|
||||
private static ProcessDueWorkflowTimersCommandHandler CreateHandler(WorkflowDbContext db)
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var engine = new ProcessEngine(db, services, new ConditionEvaluator());
|
||||
return new ProcessDueWorkflowTimersCommandHandler(
|
||||
db,
|
||||
engine,
|
||||
NullLogger<ProcessDueWorkflowTimersCommandHandler>.Instance);
|
||||
}
|
||||
|
||||
private async Task ResetAsync()
|
||||
{
|
||||
await using var db = fixture.CreateDbContext();
|
||||
await db.WorkflowTimerJobs.ExecuteDeleteAsync();
|
||||
await db.WorkflowTasks.ExecuteDeleteAsync();
|
||||
await db.WorkflowTokens.ExecuteDeleteAsync();
|
||||
await db.WorkflowInstances.ExecuteDeleteAsync();
|
||||
await db.WorkflowEdges.ExecuteDeleteAsync();
|
||||
await db.WorkflowNodes.ExecuteDeleteAsync();
|
||||
await db.WorkflowDefinitionSnapshots.ExecuteDeleteAsync();
|
||||
await db.WorkflowDefinitions.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
private async Task<SeededTimer> SeedTimerAsync(
|
||||
InstanceStatus instanceStatus,
|
||||
WorkflowTimerJobStatus jobStatus = WorkflowTimerJobStatus.Pending,
|
||||
string? leaseOwner = null,
|
||||
DateTime? leaseExpiresAt = null,
|
||||
int attemptCount = 0)
|
||||
{
|
||||
await using var db = fixture.CreateDbContext();
|
||||
var definitionId = Guid.NewGuid();
|
||||
var timerNodeId = Guid.NewGuid();
|
||||
var targetNodeId = Guid.NewGuid();
|
||||
var instanceId = Guid.NewGuid();
|
||||
var tokenId = Guid.NewGuid();
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
db.WorkflowDefinitions.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = definitionId,
|
||||
Name = "Timer测试流程",
|
||||
Code = $"timer-{definitionId:N}",
|
||||
Version = 1,
|
||||
Status = DefinitionStatus.Published,
|
||||
});
|
||||
db.WorkflowNodes.AddRange(
|
||||
new WorkflowNode
|
||||
{
|
||||
Id = timerNodeId,
|
||||
DefinitionId = definitionId,
|
||||
Name = "等待",
|
||||
NodeType = NodeType.Timer,
|
||||
},
|
||||
new WorkflowNode
|
||||
{
|
||||
Id = targetNodeId,
|
||||
DefinitionId = definitionId,
|
||||
Name = "审批",
|
||||
NodeType = NodeType.Approval,
|
||||
Config = """{ "assigneeRule": "role:user" }""",
|
||||
});
|
||||
db.WorkflowEdges.Add(new WorkflowEdge
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DefinitionId = definitionId,
|
||||
SourceNodeId = timerNodeId,
|
||||
TargetNodeId = targetNodeId,
|
||||
EdgeType = EdgeType.Normal,
|
||||
});
|
||||
db.WorkflowInstances.Add(new WorkflowInstance
|
||||
{
|
||||
Id = instanceId,
|
||||
DefinitionId = definitionId,
|
||||
DefinitionVersion = 1,
|
||||
Title = "Timer测试实例",
|
||||
Status = instanceStatus,
|
||||
Variables = "{}",
|
||||
});
|
||||
db.WorkflowTokens.Add(new WorkflowToken
|
||||
{
|
||||
Id = tokenId,
|
||||
InstanceId = instanceId,
|
||||
NodeId = timerNodeId,
|
||||
Status = TokenStatus.Active,
|
||||
});
|
||||
db.WorkflowTimerJobs.Add(new WorkflowTimerJob
|
||||
{
|
||||
Id = jobId,
|
||||
InstanceId = instanceId,
|
||||
TokenId = tokenId,
|
||||
NodeId = timerNodeId,
|
||||
DueAt = DateTime.UtcNow.AddMinutes(-1),
|
||||
Status = jobStatus,
|
||||
LeaseOwner = leaseOwner,
|
||||
LeaseExpiresAt = leaseExpiresAt,
|
||||
AttemptCount = attemptCount,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new SeededTimer(jobId, instanceId, tokenId, targetNodeId);
|
||||
}
|
||||
|
||||
private sealed record SeededTimer(
|
||||
Guid JobId,
|
||||
Guid InstanceId,
|
||||
Guid TokenId,
|
||||
Guid TargetNodeId);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user