Phase 3 PR 33 — DriverHostStatus entity + migration (LMX #7 data layer) #32

Merged
dohertj2 merged 1 commits from phase-3-pr33-driverhoststatus-entity into v2 2026-04-18 15:43:39 -04:00
8 changed files with 1576 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per-host connectivity snapshot the Server publishes for each driver's
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
/// rows, not 3, because each server node owns its own runtime view.
/// </summary>
/// <remarks>
/// <para>
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
/// drill-down). The publisher hosted service on the Server side subscribes to every
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
/// </para>
/// <para>
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
/// rather keep the status row than drop it. The Admin-side service left-joins on
/// NodeId when presenting rows.
/// </para>
/// </remarks>
public sealed class DriverHostStatus
{
/// <summary>Server node that's running the driver.</summary>
public required string NodeId { get; set; }
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
/// a display string.
/// </summary>
public required string HostName { get; set; }
public DriverHostState State { get; set; } = DriverHostState.Unknown;
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
public DateTime StateChangedUtc { get; set; }
/// <summary>
/// Advances on every publisher heartbeat — the Admin UI uses
/// <c>now - LastSeenUtc &gt; threshold</c> to flag rows whose owning Server has
/// stopped reporting (crashed, network-partitioned, etc.), independent of
/// <see cref="State"/>.
/// </summary>
public DateTime LastSeenUtc { get; set; }
/// <summary>
/// Optional human-readable detail populated when <see cref="State"/> is
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
/// driver's probe. Null for Running / Stopped / Unknown transitions.
/// </summary>
public string? Detail { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
/// Configuration project stays free of driver-runtime dependencies.
/// </summary>
/// <remarks>
/// The server-side publisher (follow-up PR) translates
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
/// </remarks>
public enum DriverHostState
{
Unknown,
Running,
Stopped,
Faulted,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverHostStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverHostStatus",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_LastSeen",
table: "DriverHostStatus",
column: "LastSeenUtc");
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_Node",
table: "DriverHostStatus",
column: "NodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverHostStatus");
}
}
}

View File

@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
{
b.Property<string>("NodeId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HostName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Detail")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("StateChangedUtc")
.HasColumnType("datetime2(3)");
b.HasKey("NodeId", "DriverInstanceId", "HostName");
b.HasIndex("LastSeenUtc")
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
b.HasIndex("NodeId")
.HasDatabaseName("IX_DriverHostStatus_Node");
b.ToTable("DriverHostStatus", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
{
b.Property<Guid>("DriverInstanceRowId")

View File

@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureClusterNodeGenerationState(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder);
ConfigureDriverHostStatus(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
});
}
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverHostStatus>(e =>
{
e.ToTable("DriverHostStatus");
// Composite key — one row per (server node, driver instance, probe-reported host).
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
// 6 rows because each server node owns its own runtime view; the composite key is
// what lets both views coexist without shadowing each other.
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.HostName).HasMaxLength(256);
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
e.Property(x => x.Detail).HasMaxLength(1024);
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
});
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
{
[Fact]
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
{
await using var ctx = NewContext();
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
// redundancy case. Both rows must be insertable because each server node owns its own
// runtime view of the shared host.
var now = DateTime.UtcNow;
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
State = DriverHostState.Running,
StateChangedUtc = now, LastSeenUtc = now,
});
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
State = DriverHostState.Stopped,
StateChangedUtc = now, LastSeenUtc = now,
Detail = "secondary hasn't taken over yet",
});
// Same server node + host, different driver instance — second driver doesn't clobber.
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
State = DriverHostState.Running,
StateChangedUtc = now, LastSeenUtc = now,
});
await ctx.SaveChangesAsync();
var rows = await ctx.DriverHostStatuses.AsNoTracking()
.Where(r => r.HostName == "GRPlatform").ToListAsync();
rows.Count.ShouldBe(3);
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
}
[Fact]
public async Task Upsert_pattern_for_same_key_updates_in_place()
{
// The publisher hosted service (follow-up PR) upserts on every transition +
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
// keyed on the composite PK. If the composite key ever changes, this test breaks
// loudly so the publisher gets a synchronized update.
await using var ctx = NewContext();
var t0 = DateTime.UtcNow;
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
State = DriverHostState.Running,
StateChangedUtc = t0, LastSeenUtc = t0,
});
await ctx.SaveChangesAsync();
var t1 = t0.AddSeconds(30);
await using (var ctx2 = NewContext())
{
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
existing.State = DriverHostState.Faulted;
existing.StateChangedUtc = t1;
existing.LastSeenUtc = t1;
existing.Detail = "transport reset by peer";
await ctx2.SaveChangesAsync();
}
await using var ctx3 = NewContext();
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
final.State.ShouldBe(DriverHostState.Faulted);
final.Detail.ShouldBe("transport reset by peer");
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
}
[Fact]
public async Task Enum_persists_as_string_not_int()
{
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
// string back via ADO; if someone drops the conversion the column will contain '1'
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
// directly should see readable state names, not enum ordinals.
await using var ctx = NewContext();
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
State = DriverHostState.Faulted,
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
await using var conn = fixture.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
var rawValue = (string?)await cmd.ExecuteScalarAsync();
rawValue.ShouldBe("Faulted");
}
private OtOpcUaConfigDbContext NewContext()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(fixture.ConnectionString)
.Options;
return new OtOpcUaConfigDbContext(options);
}
}

View File

@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
"Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
};
var actual = QueryStrings(@"