feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync
This commit is contained in:
@@ -51,7 +51,8 @@ public class TrackingApiTests
|
||||
UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
|
||||
TerminalAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
|
||||
SourceInstanceId: "Plant.Pump42",
|
||||
SourceScript: "ScriptActor:OnTick");
|
||||
SourceScript: "ScriptActor:OnTick",
|
||||
SourceNode: null);
|
||||
|
||||
var store = new Mock<IOperationTrackingStore>();
|
||||
store
|
||||
|
||||
@@ -59,6 +59,7 @@ public class OperationTrackingStoreTests
|
||||
"TrackedOperationId", "Kind", "TargetSummary", "Status",
|
||||
"RetryCount", "LastError", "HttpStatus", "CreatedAtUtc",
|
||||
"UpdatedAtUtc", "TerminalAtUtc", "SourceInstanceId", "SourceScript",
|
||||
"SourceNode",
|
||||
};
|
||||
Assert.Equal(
|
||||
expected.OrderBy(n => n),
|
||||
@@ -70,6 +71,159 @@ public class OperationTrackingStoreTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_creates_OperationTracking_with_SourceNode_column()
|
||||
{
|
||||
var (store, dataSource) = CreateStore(nameof(Initialize_creates_OperationTracking_with_SourceNode_column));
|
||||
using (store)
|
||||
{
|
||||
using var connection = OpenVerifierConnection(dataSource);
|
||||
Assert.True(
|
||||
ColumnExists(connection, "SourceNode"),
|
||||
"Fresh OperationTracking schema must include the SourceNode column.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pre-SourceNode <c>OperationTracking</c> schema — the 12-column
|
||||
/// CREATE TABLE that has the original source-provenance columns
|
||||
/// (<c>SourceInstanceId</c>, <c>SourceScript</c>) but is WITHOUT
|
||||
/// <c>SourceNode</c>. A deployment that ran before the SourceNode
|
||||
/// stamping work already has an on-disk <c>tracking.db</c> in exactly
|
||||
/// this shape, and <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
|
||||
/// </summary>
|
||||
private const string OldPreSourceNodeSchema = """
|
||||
CREATE TABLE IF NOT EXISTS OperationTracking (
|
||||
TrackedOperationId TEXT NOT NULL PRIMARY KEY,
|
||||
Kind TEXT NOT NULL,
|
||||
TargetSummary TEXT NULL,
|
||||
Status TEXT NOT NULL,
|
||||
RetryCount INTEGER NOT NULL DEFAULT 0,
|
||||
LastError TEXT NULL,
|
||||
HttpStatus INTEGER NULL,
|
||||
CreatedAtUtc TEXT NOT NULL,
|
||||
UpdatedAtUtc TEXT NOT NULL,
|
||||
TerminalAtUtc TEXT NULL,
|
||||
SourceInstanceId TEXT NULL,
|
||||
SourceScript TEXT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated
|
||||
ON OperationTracking (Status, UpdatedAtUtc);
|
||||
""";
|
||||
|
||||
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource)
|
||||
{
|
||||
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = OldPreSourceNodeSchema;
|
||||
cmd.ExecuteNonQuery();
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static bool ColumnExists(SqliteConnection connection, string columnName)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('OperationTracking') WHERE name = $name";
|
||||
cmd.Parameters.AddWithValue("$name", columnName);
|
||||
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
|
||||
}
|
||||
|
||||
private static OperationTrackingStore CreateStoreOver(string dataSource)
|
||||
{
|
||||
var connectionString = $"Data Source={dataSource};Cache=Shared";
|
||||
var options = new OperationTrackingOptions { ConnectionString = connectionString };
|
||||
return new OperationTrackingStore(
|
||||
Options.Create(options),
|
||||
NullLogger<OperationTrackingStore>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
|
||||
{
|
||||
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
|
||||
|
||||
// A pre-SourceNode deployment: tracking.db already exists with the
|
||||
// 12-column schema and NO SourceNode column.
|
||||
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource);
|
||||
Assert.True(ColumnExists(seedConnection, "SourceInstanceId"));
|
||||
Assert.True(ColumnExists(seedConnection, "SourceScript"));
|
||||
Assert.False(ColumnExists(seedConnection, "SourceNode"));
|
||||
|
||||
// Upgrade: a post-branch OperationTrackingStore opens the same database.
|
||||
// Its InitializeSchema must ALTER the missing SourceNode column in —
|
||||
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
|
||||
// table.
|
||||
await using (var store = CreateStoreOver(dataSource))
|
||||
{
|
||||
Assert.True(
|
||||
ColumnExists(seedConnection, "SourceNode"),
|
||||
"OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table.");
|
||||
|
||||
// A RecordEnqueueAsync binding $sourceNode must now succeed; without
|
||||
// the ALTER it would fail with "no such column: SourceNode".
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(
|
||||
id,
|
||||
kind: "ApiCallCached",
|
||||
targetSummary: "ERP.GetOrder",
|
||||
sourceInstanceId: "inst-1",
|
||||
sourceScript: "ScriptActor:OnTick",
|
||||
sourceNode: "node-a");
|
||||
|
||||
var snapshot = await store.GetStatusAsync(id);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("node-a", snapshot!.SourceNode);
|
||||
}
|
||||
|
||||
// Idempotency: a second store over the now-upgraded DB must not error
|
||||
// (the probe sees SourceNode already present and skips the ALTER).
|
||||
await using (var storeAgain = CreateStoreOver(dataSource))
|
||||
{
|
||||
Assert.True(ColumnExists(seedConnection, "SourceNode"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEnqueueAsync_persists_SourceNode()
|
||||
{
|
||||
var (store, _) = CreateStore(nameof(RecordEnqueueAsync_persists_SourceNode));
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(
|
||||
id,
|
||||
kind: nameof(AuditKind.ApiCallCached),
|
||||
targetSummary: "ERP.GetOrder",
|
||||
sourceInstanceId: "Plant.Pump42",
|
||||
sourceScript: "ScriptActor:OnTick",
|
||||
sourceNode: "node-a");
|
||||
|
||||
var snapshot = await store.GetStatusAsync(id);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("node-a", snapshot!.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEnqueueAsync_persists_null_SourceNode()
|
||||
{
|
||||
var (store, _) = CreateStore(nameof(RecordEnqueueAsync_persists_null_SourceNode));
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(
|
||||
id,
|
||||
kind: nameof(AuditKind.ApiCallCached),
|
||||
targetSummary: "ERP.GetOrder",
|
||||
sourceInstanceId: null,
|
||||
sourceScript: null,
|
||||
sourceNode: null);
|
||||
|
||||
var snapshot = await store.GetStatusAsync(id);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Null(snapshot!.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero()
|
||||
{
|
||||
@@ -82,7 +236,8 @@ public class OperationTrackingStoreTests
|
||||
kind: nameof(AuditKind.ApiCallCached),
|
||||
targetSummary: "ERP.GetOrder",
|
||||
sourceInstanceId: "Plant.Pump42",
|
||||
sourceScript: "ScriptActor:OnTick");
|
||||
sourceScript: "ScriptActor:OnTick",
|
||||
sourceNode: null);
|
||||
|
||||
var snapshot = await store.GetStatusAsync(id);
|
||||
Assert.NotNull(snapshot);
|
||||
@@ -107,8 +262,8 @@ public class OperationTrackingStoreTests
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick");
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other");
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick", sourceNode: null);
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other", sourceNode: null);
|
||||
|
||||
var snapshot = await store.GetStatusAsync(id);
|
||||
Assert.NotNull(snapshot);
|
||||
@@ -127,7 +282,7 @@ public class OperationTrackingStoreTests
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
|
||||
|
||||
await store.RecordAttemptAsync(
|
||||
id,
|
||||
@@ -155,7 +310,7 @@ public class OperationTrackingStoreTests
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
|
||||
await store.RecordTerminalAsync(
|
||||
id,
|
||||
status: nameof(AuditStatus.Delivered),
|
||||
@@ -190,7 +345,7 @@ public class OperationTrackingStoreTests
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
|
||||
|
||||
var beforeTerminal = DateTime.UtcNow;
|
||||
await store.RecordTerminalAsync(
|
||||
@@ -228,7 +383,7 @@ public class OperationTrackingStoreTests
|
||||
await using var _store = store;
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
|
||||
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null);
|
||||
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 1, "first failure", 503);
|
||||
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 2, "second failure", 503);
|
||||
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 3, "third failure", 504);
|
||||
@@ -254,9 +409,9 @@ public class OperationTrackingStoreTests
|
||||
var bId = TrackedOperationId.New();
|
||||
var cId = TrackedOperationId.New();
|
||||
|
||||
await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null);
|
||||
await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null);
|
||||
await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null);
|
||||
await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null, sourceNode: null);
|
||||
await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null, sourceNode: null);
|
||||
await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null, sourceNode: null);
|
||||
|
||||
await store.RecordTerminalAsync(aId, nameof(AuditStatus.Delivered), null, 200);
|
||||
await store.RecordTerminalAsync(bId, nameof(AuditStatus.Delivered), null, 200);
|
||||
|
||||
Reference in New Issue
Block a user