feat(persistence): executions + node_executions migrations and repositories

This commit is contained in:
Claude
2026-08-28 14:41:21 +00:00
parent b645d1b2ab
commit 37fb16bc43
7 changed files with 389 additions and 0 deletions

View File

@@ -74,10 +74,14 @@ builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
new Migration001Initial(),
new Migration002Connections(),
new Migration003Workflows(),
new Migration004Executions(),
new Migration005NodeExecutions(),
}));
builder.Services.AddSingleton<PasswordService>();
builder.Services.AddSingleton<ConnectionRepository>();
builder.Services.AddSingleton<WorkflowRepository>();
builder.Services.AddSingleton<ExecutionRepository>();
builder.Services.AddSingleton<NodeExecutionRepository>();
builder.Services.AddSingleton<IConnectionSecretPolicy, SchemaSecretPolicy>();
builder.Services

View File

@@ -0,0 +1,27 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Migrations;
public sealed class Migration004Executions : IMigration
{
public int Version => 4;
public void Apply(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE executions (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
trigger TEXT NOT NULL, -- 'nats.hook' | 'http.webhook' | 'manual'
trigger_data_json TEXT NOT NULL,
status TEXT NOT NULL, -- 'running' | 'completed' | 'partial' | 'failed' | 'aborted'
started_at TEXT NOT NULL,
ended_at TEXT,
error TEXT
);
CREATE INDEX ix_executions_workflow_started ON executions (workflow_id, started_at DESC);
CREATE INDEX ix_executions_ended_at ON executions (ended_at);
";
cmd.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Migrations;
public sealed class Migration005NodeExecutions : IMigration
{
public int Version => 5;
public void Apply(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE node_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_id TEXT NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
inputs_json TEXT NOT NULL, -- [[items on port 0], [items on port 1], ...] or []
outputs_json TEXT NOT NULL, -- [[items on out 0], ...] or []
status TEXT NOT NULL, -- 'completed' | 'failed' | 'skipped_due_to_failure'
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
error TEXT
);
CREATE INDEX ix_node_executions_by_execution ON node_executions (execution_id);
";
cmd.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Repositories;
public sealed record ExecutionRow(
string Id, string WorkflowId, string Trigger, string TriggerDataJson,
string Status, DateTimeOffset StartedAt, DateTimeOffset? EndedAt, string? Error);
public sealed class ExecutionRepository
{
private readonly SqliteConnectionFactory _factory;
public ExecutionRepository(SqliteConnectionFactory f) => _factory = f;
public string Create(string workflowId, string trigger, string triggerDataJson)
{
var id = Guid.NewGuid().ToString("N");
var now = DateTimeOffset.UtcNow.ToString("o");
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO executions (id, workflow_id, trigger, trigger_data_json, status, started_at)
VALUES ($id, $w, $t, $td, 'running', $now);
";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$w", workflowId);
cmd.Parameters.AddWithValue("$t", trigger);
cmd.Parameters.AddWithValue("$td", triggerDataJson);
cmd.Parameters.AddWithValue("$now", now);
cmd.ExecuteNonQuery();
return id;
}
public void SetStatus(string id, string status, string? error, DateTimeOffset? endedAt)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"UPDATE executions
SET status = $s, error = $e, ended_at = $end
WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$s", status);
cmd.Parameters.AddWithValue("$e", (object?)error ?? DBNull.Value);
cmd.Parameters.AddWithValue("$end", endedAt is null ? DBNull.Value : (object)endedAt.Value.ToString("o"));
cmd.ExecuteNonQuery();
}
public ExecutionRow? Get(string id)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, workflow_id, trigger, trigger_data_json, status,
started_at, ended_at, error
FROM executions WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
return r.Read() ? Materialize(r) : null;
}
public IReadOnlyList<ExecutionRow> ListForWorkflow(string workflowId, int limit)
{
var rows = new List<ExecutionRow>();
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, workflow_id, trigger, trigger_data_json, status,
started_at, ended_at, error
FROM executions
WHERE workflow_id = $w
ORDER BY started_at DESC
LIMIT $lim;";
cmd.Parameters.AddWithValue("$w", workflowId);
cmd.Parameters.AddWithValue("$lim", limit);
using var r = cmd.ExecuteReader();
while (r.Read()) rows.Add(Materialize(r));
return rows;
}
public int AbortAllRunning()
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"UPDATE executions
SET status = 'aborted', ended_at = $now
WHERE status = 'running';";
cmd.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("o"));
return cmd.ExecuteNonQuery();
}
public int PruneOlderThan(DateTimeOffset cutoff)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"DELETE FROM executions
WHERE ended_at IS NOT NULL AND ended_at < $c;";
cmd.Parameters.AddWithValue("$c", cutoff.ToString("o"));
return cmd.ExecuteNonQuery();
}
public int PruneOldestPerWorkflow(int keep)
{
// For each workflow, delete rows that are not in the newest `keep` by started_at.
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
DELETE FROM executions WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY workflow_id ORDER BY started_at DESC
) AS rn
FROM executions
) WHERE rn > $keep
);";
cmd.Parameters.AddWithValue("$keep", keep);
return cmd.ExecuteNonQuery();
}
private static ExecutionRow Materialize(SqliteDataReader r) => new(
r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4),
DateTimeOffset.Parse(r.GetString(5)),
r.IsDBNull(6) ? null : DateTimeOffset.Parse(r.GetString(6)),
r.IsDBNull(7) ? null : r.GetString(7));
}

View File

@@ -0,0 +1,58 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Repositories;
public sealed record NodeExecutionRow(
long Id, string ExecutionId, string NodeId,
string InputsJson, string OutputsJson,
string Status, DateTimeOffset StartedAt, DateTimeOffset EndedAt, string? Error);
public sealed class NodeExecutionRepository
{
private readonly SqliteConnectionFactory _factory;
public NodeExecutionRepository(SqliteConnectionFactory f) => _factory = f;
public void Create(string executionId, string nodeId,
string inputsJson, string outputsJson,
string status, DateTimeOffset startedAt, DateTimeOffset endedAt, string? error)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO node_executions
(execution_id, node_id, inputs_json, outputs_json, status, started_at, ended_at, error)
VALUES ($eid, $nid, $inp, $out, $s, $sa, $ea, $err);
";
cmd.Parameters.AddWithValue("$eid", executionId);
cmd.Parameters.AddWithValue("$nid", nodeId);
cmd.Parameters.AddWithValue("$inp", inputsJson);
cmd.Parameters.AddWithValue("$out", outputsJson);
cmd.Parameters.AddWithValue("$s", status);
cmd.Parameters.AddWithValue("$sa", startedAt.ToString("o"));
cmd.Parameters.AddWithValue("$ea", endedAt.ToString("o"));
cmd.Parameters.AddWithValue("$err", (object?)error ?? DBNull.Value);
cmd.ExecuteNonQuery();
}
public IReadOnlyList<NodeExecutionRow> ListByExecution(string executionId)
{
var rows = new List<NodeExecutionRow>();
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, execution_id, node_id, inputs_json, outputs_json,
status, started_at, ended_at, error
FROM node_executions
WHERE execution_id = $eid
ORDER BY started_at ASC, id ASC;";
cmd.Parameters.AddWithValue("$eid", executionId);
using var r = cmd.ExecuteReader();
while (r.Read())
rows.Add(new NodeExecutionRow(
r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4),
r.GetString(5),
DateTimeOffset.Parse(r.GetString(6)),
DateTimeOffset.Parse(r.GetString(7)),
r.IsDBNull(8) ? null : r.GetString(8)));
return rows;
}
}