clean and last run and count
This commit is contained in:
parent
17d4515961
commit
0d71f39245
|
|
@ -86,8 +86,9 @@ public class WorkflowsController : ControllerBase
|
||||||
})
|
})
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// Latest run per workflow (status + finish time) so the list can surface
|
// Latest run per workflow (status, time + duration) and total run count so
|
||||||
// the last execution in one column without a round-trip per row.
|
// the list can surface the last execution and execution count without a
|
||||||
|
// round-trip per row.
|
||||||
var ids = workflows.Select(w => w.Id).ToList();
|
var ids = workflows.Select(w => w.Id).ToList();
|
||||||
var lastRuns = await _db.WorkflowRuns
|
var lastRuns = await _db.WorkflowRuns
|
||||||
.Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId))
|
.Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId))
|
||||||
|
|
@ -95,6 +96,7 @@ public class WorkflowsController : ControllerBase
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
WorkflowId = g.Key,
|
WorkflowId = g.Key,
|
||||||
|
RunCount = g.Count(),
|
||||||
Status = g
|
Status = g
|
||||||
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
|
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
|
||||||
.ThenByDescending(r => r.Id)
|
.ThenByDescending(r => r.Id)
|
||||||
|
|
@ -137,8 +139,12 @@ public class WorkflowsController : ControllerBase
|
||||||
w.Version,
|
w.Version,
|
||||||
w.taskCount,
|
w.taskCount,
|
||||||
w.upToDate,
|
w.upToDate,
|
||||||
|
runCount = last?.RunCount ?? 0,
|
||||||
lastRunStatus = last?.Status,
|
lastRunStatus = last?.Status,
|
||||||
lastRunAt = last?.FinishedAt ?? last?.StartedAt,
|
lastRunAt = last?.FinishedAt ?? last?.StartedAt,
|
||||||
|
lastRunDuration = last is { StartedAt: DateTime s, FinishedAt: DateTime f }
|
||||||
|
? (f - s).TotalSeconds
|
||||||
|
: (double?)null,
|
||||||
lastRunError = last?.Error,
|
lastRunError = last?.Error,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -429,4 +435,48 @@ public class WorkflowsController : ControllerBase
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message });
|
return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the run history for one workflow: deletes every run (and, via the
|
||||||
|
/// FK cascade, its task runs). The workflow definition, its tasks and its
|
||||||
|
/// trigger are untouched — only the history is wiped. Returns the number of
|
||||||
|
/// runs removed.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{id:guid}/runs")]
|
||||||
|
[RequireScope("manage")]
|
||||||
|
public async Task<IActionResult> ClearRuns(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var owned = await _db.Workflows.AnyAsync(w => w.Id == id && w.TenantId == TenantId, ct);
|
||||||
|
if (!owned)
|
||||||
|
return NotFound(new { error = "Workflow not found." });
|
||||||
|
|
||||||
|
var runIds = await _db.WorkflowRuns
|
||||||
|
.Where(r => r.WorkflowId == id && r.TenantId == TenantId)
|
||||||
|
.Select(r => r.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (runIds.Count > 0)
|
||||||
|
{
|
||||||
|
// Remove task runs explicitly first so EF does not depend on the DB
|
||||||
|
// cascade for the (already configured) FK; then remove the runs.
|
||||||
|
var taskRuns = await _db.TaskRuns
|
||||||
|
.Where(t => runIds.Contains(t.RunId))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
_db.TaskRuns.RemoveRange(taskRuns);
|
||||||
|
|
||||||
|
var runs = await _db.WorkflowRuns
|
||||||
|
.Where(r => runIds.Contains(r.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
_db.WorkflowRuns.RemoveRange(runs);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Cleared {Count} run(s) for workflow {WorkflowId} (tenant {TenantId})",
|
||||||
|
runIds.Count, id, TenantId);
|
||||||
|
return Ok(new ClearRunsResponse(runIds.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ClearRunsResponse(int Cleared);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
155
w4c-workflows-api.Tests/WorkflowsControllerListAndClearTests.cs
Normal file
155
w4c-workflows-api.Tests/WorkflowsControllerListAndClearTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using w4c_workflows.Controllers;
|
||||||
|
using w4c_workflows.Data;
|
||||||
|
using w4c_workflows.Models;
|
||||||
|
using w4c_workflows.Services;
|
||||||
|
using w4c_workflows.Services.Runs;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
using ClearRunsResponse = w4c_workflows.Controllers.WorkflowsController.ClearRunsResponse;
|
||||||
|
|
||||||
|
namespace w4c_workflows.Tests;
|
||||||
|
|
||||||
|
[Collection("WorkflowsPostgres")]
|
||||||
|
public class WorkflowsControllerListAndClearTests
|
||||||
|
{
|
||||||
|
private const string YamlContent = "name: linear\nmode: function\nlanguage: shell\n";
|
||||||
|
|
||||||
|
private sealed class FakeWorkflowSource : IWorkflowSource
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||||||
|
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(Array.Empty<WorkflowFile>());
|
||||||
|
|
||||||
|
public Task<string> ReadAsync(string path, CancellationToken ct)
|
||||||
|
=> Task.FromResult(YamlContent);
|
||||||
|
|
||||||
|
public WorkflowSourceState GetState() => new(null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly WorkflowsPostgresFixture _fixture;
|
||||||
|
|
||||||
|
public WorkflowsControllerListAndClearTests(WorkflowsPostgresFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowsController Controller(WorkflowsDbContext db, string tenantId)
|
||||||
|
{
|
||||||
|
var sync = new WorkflowSyncService(
|
||||||
|
db,
|
||||||
|
new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry())),
|
||||||
|
new FakeWorkflowSource(),
|
||||||
|
NullLogger<WorkflowSyncService>.Instance);
|
||||||
|
|
||||||
|
var controller = new WorkflowsController(
|
||||||
|
db,
|
||||||
|
sync,
|
||||||
|
new FakeWorkflowSource(),
|
||||||
|
new MermaidGeneratorService(),
|
||||||
|
new WorkflowHtmlRenderer(NullLogger<WorkflowHtmlRenderer>.Instance),
|
||||||
|
new RenderTokenService(new ConfigurationBuilder().Build(), NullLogger<RenderTokenService>.Instance),
|
||||||
|
new RunLauncher(db, NullLogger<RunLauncher>.Instance),
|
||||||
|
new DurableStateStore(db),
|
||||||
|
NullLogger<WorkflowsController>.Instance)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||||
|
};
|
||||||
|
controller.HttpContext.Items["TenantId"] = tenantId;
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Tenant() => "t" + Guid.NewGuid().ToString("N")[..12];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_returns_run_count_and_last_run_duration()
|
||||||
|
{
|
||||||
|
var tenantId = Tenant();
|
||||||
|
await using var db = _fixture.CreateContext();
|
||||||
|
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||||
|
|
||||||
|
// Two finished runs + one in-flight run (started longest ago so the latest
|
||||||
|
// run by time is the finished one) for the same workflow.
|
||||||
|
WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Succeeded,
|
||||||
|
startedAt: DateTime.UtcNow.AddMinutes(-5), finishedAt: DateTime.UtcNow.AddMinutes(-4));
|
||||||
|
WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Failed,
|
||||||
|
startedAt: DateTime.UtcNow.AddMinutes(-2), finishedAt: DateTime.UtcNow.AddMinutes(-1), error: "boom");
|
||||||
|
WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Running, startedAt: DateTime.UtcNow.AddMinutes(-10));
|
||||||
|
|
||||||
|
// A workflow with no runs must default to 0 / null, not throw.
|
||||||
|
var empty = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.DurableYaml, "workflows/b.yaml");
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await Controller(db, tenantId).List(default));
|
||||||
|
var json = JsonSerializer.Serialize(result.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var first = root.GetProperty("workflows").EnumerateArray()
|
||||||
|
.Single(w => w.GetProperty("id").GetGuid() == workflow.Id);
|
||||||
|
// The latest run is the failed one (started -2m), so duration = 60s.
|
||||||
|
Assert.Equal(3, first.GetProperty("runCount").GetInt32());
|
||||||
|
Assert.Equal(RunStatus.Failed, first.GetProperty("lastRunStatus").GetString());
|
||||||
|
Assert.Equal(60, first.GetProperty("lastRunDuration").GetDouble(), 1);
|
||||||
|
|
||||||
|
var second = root.GetProperty("workflows").EnumerateArray()
|
||||||
|
.Single(w => w.GetProperty("id").GetGuid() == empty.Id);
|
||||||
|
Assert.Equal(0, second.GetProperty("runCount").GetInt32());
|
||||||
|
Assert.Equal(JsonValueKind.Null, second.GetProperty("lastRunDuration").ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Null, second.GetProperty("lastRunStatus").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearRuns_deletes_runs_and_task_runs_for_the_workflow()
|
||||||
|
{
|
||||||
|
var tenantId = Tenant();
|
||||||
|
await using var db = _fixture.CreateContext();
|
||||||
|
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||||
|
var done = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Succeeded,
|
||||||
|
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(-4));
|
||||||
|
var failed = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Failed,
|
||||||
|
DateTime.UtcNow.AddMinutes(-2), DateTime.UtcNow.AddMinutes(-1), error: "boom");
|
||||||
|
|
||||||
|
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||||
|
db.TaskRuns.AddRange(
|
||||||
|
new TaskRun { Id = Guid.NewGuid(), RunId = done.Id, TaskId = root.Id, Attempt = 1, Status = TaskRunStatus.Succeeded },
|
||||||
|
new TaskRun { Id = Guid.NewGuid(), RunId = failed.Id, TaskId = root.Id, Attempt = 1, Status = TaskRunStatus.Failed });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await Controller(db, tenantId).ClearRuns(workflow.Id, default));
|
||||||
|
var body = Assert.IsType<ClearRunsResponse>(result.Value);
|
||||||
|
Assert.Equal(2, body.Cleared);
|
||||||
|
|
||||||
|
Assert.Empty(await db.WorkflowRuns.Where(r => r.WorkflowId == workflow.Id).ToListAsync());
|
||||||
|
Assert.Empty(await db.TaskRuns.Where(t => t.RunId == done.Id || t.RunId == failed.Id).ToListAsync());
|
||||||
|
// The workflow definition itself is untouched.
|
||||||
|
Assert.True(await db.Workflows.AnyAsync(w => w.Id == workflow.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearRuns_404s_for_foreign_workflow()
|
||||||
|
{
|
||||||
|
var tenantId = Tenant();
|
||||||
|
await using var db = _fixture.CreateContext();
|
||||||
|
var other = Tenant();
|
||||||
|
var workflow = WorkflowDataHelpers.CompileAndSave(db, other, WorkflowDataHelpers.LinearYaml);
|
||||||
|
|
||||||
|
var result = await Controller(db, tenantId).ClearRuns(workflow.Id, default);
|
||||||
|
Assert.IsType<NotFoundObjectResult>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearRuns_is_idempotent_when_no_runs_exist()
|
||||||
|
{
|
||||||
|
var tenantId = Tenant();
|
||||||
|
await using var db = _fixture.CreateContext();
|
||||||
|
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await Controller(db, tenantId).ClearRuns(workflow.Id, default));
|
||||||
|
var body = Assert.IsType<ClearRunsResponse>(result.Value);
|
||||||
|
Assert.Equal(0, body.Cleared);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue