84 lines
3 KiB
C#
84 lines
3 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using w4c_workflows.Data;
|
|
using w4c_workflows.Models;
|
|
using w4c_workflows.Services.Quota;
|
|
|
|
namespace w4c_workflows.Services.Runs;
|
|
|
|
/// <summary>Request to start a workflow run.</summary>
|
|
public sealed record LaunchRequest(
|
|
string TenantId,
|
|
Guid WorkflowId,
|
|
string? TriggerJson,
|
|
string? InputJson,
|
|
string? CorrelationId,
|
|
Guid? StartTaskId = null);
|
|
|
|
/// <summary>
|
|
/// Starts a run for a compiled workflow by creating a <c>pending</c>
|
|
/// <see cref="WorkflowRun"/> row. This is the seam between the trigger engine
|
|
/// (step 8 — decides WHEN to run) and the run lifecycle engine (step 9 — owns
|
|
/// dispatch via Redis, worker result consumption, chain advancement and durable
|
|
/// checkpoint/resume). The lifecycle engine picks up <c>pending</c> runs and
|
|
/// drives them to completion, so the trigger engine never touches the jobs
|
|
/// stream.
|
|
/// </summary>
|
|
public interface IRunLauncher
|
|
{
|
|
Task<Guid> LaunchAsync(LaunchRequest request, CancellationToken ct);
|
|
}
|
|
|
|
public class RunLauncher : IRunLauncher
|
|
{
|
|
private readonly WorkflowsDbContext _db;
|
|
private readonly WorkflowQuotaService _quota;
|
|
private readonly ILogger<RunLauncher> _logger;
|
|
|
|
public RunLauncher(WorkflowsDbContext db, WorkflowQuotaService quota, ILogger<RunLauncher> logger)
|
|
{
|
|
_db = db;
|
|
_quota = quota;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<Guid> LaunchAsync(LaunchRequest request, CancellationToken ct)
|
|
{
|
|
var workflow = await _db.Workflows
|
|
.FirstOrDefaultAsync(w =>
|
|
w.Id == request.WorkflowId
|
|
&& w.TenantId == request.TenantId
|
|
&& w.Status == WorkflowStatus.Compiled, ct);
|
|
|
|
if (workflow == null)
|
|
throw new InvalidOperationException(
|
|
$"workflow {request.WorkflowId} not found or not compiled for tenant {request.TenantId}");
|
|
|
|
// Execution quota: only top-level runs are metered (sub-workflow children
|
|
// are created directly, not through the launcher). The reservation is an
|
|
// atomic increment, so a burst of concurrent triggers can never oversell.
|
|
if (!await _quota.TryReserveAsync(request.TenantId, ct))
|
|
throw new WorkflowQuotaExceededException(await _quota.GetAsync(request.TenantId, ct));
|
|
|
|
var run = new WorkflowRun
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
WorkflowId = workflow.Id,
|
|
TenantId = request.TenantId,
|
|
Status = RunStatus.Pending,
|
|
TriggerJson = request.TriggerJson,
|
|
InputJson = request.InputJson,
|
|
CorrelationId = request.CorrelationId,
|
|
StartTaskId = request.StartTaskId,
|
|
};
|
|
|
|
_db.WorkflowRuns.Add(run);
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
_logger.LogDebug(
|
|
"Launched pending run {RunId} for workflow {WorkflowId} (tenant {TenantId}, correlation {CorrelationId})",
|
|
run.Id, workflow.Id, request.TenantId, request.CorrelationId);
|
|
|
|
return run.Id;
|
|
}
|
|
}
|