feat(mesh-phase4): DriverHostActor runs ConfigDb-free (LocalDb alarm store, guarded acks)
Nullable ConfigDb factory; UpsertNodeDeploymentState no-ops when absent (central persists acks from the ApplyAck); scripted-alarm condition state served from the LocalDb store instead of the ConfigDb-backed Ef store. Combines Phase-4 Tasks 4 + 6. Removes the interim dbFactory! cast. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -11,7 +11,9 @@ using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
@@ -107,6 +109,18 @@ public sealed class LocalDbWiringTests : IDisposable
|
||||
.ShouldBeOfType<LocalDbDeploymentArtifactCache>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverGraph_AlarmStateStoreResolvesToTheLocalDbBackedImplementation()
|
||||
{
|
||||
// Phase 4: scripted-alarm condition state is served from the replicated LocalDb store on a
|
||||
// driver node. A dropped registration would silently fall the actor back to the EF store (or,
|
||||
// DB-less, skip the alarm host) — this pins the LocalDb store as the resolved implementation.
|
||||
var sp = BuildDriverGraph();
|
||||
|
||||
sp.GetService<IAlarmStateStore>()
|
||||
.ShouldBeOfType<LocalDbAlarmConditionStateStore>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer()
|
||||
{
|
||||
@@ -142,6 +156,8 @@ public sealed class LocalDbWiringTests : IDisposable
|
||||
|
||||
sp.GetService<ILocalDb>().ShouldBeNull();
|
||||
sp.GetService<IDeploymentArtifactCache>().ShouldBeNull();
|
||||
// Phase 4: an admin-only node never registers the LocalDb alarm store either.
|
||||
sp.GetService<IAlarmStateStore>().ShouldBeNull();
|
||||
|
||||
// The unconditionally-registered health check must still resolve and construct — it is meant
|
||||
// to no-op to Healthy when LocalDb is absent, not to fail because ISyncStatus is missing.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Per-cluster mesh Phase 4 — a driver-only node runs with NO ConfigDb: <c>dbFactory</c> is null,
|
||||
/// the redundant SQL ack-writes no-op (central persists the ack from the <c>ApplyAck</c>), and
|
||||
/// scripted-alarm condition state is served from the replicated LocalDb store instead of the
|
||||
/// ConfigDb-backed EF store. These tests drive a <c>FetchAndCache</c> actor with a null
|
||||
/// <c>dbFactory</c> — the shape a real driver-only node boots in.
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), $"otopcua-dbless-{Guid.NewGuid():N}.db");
|
||||
private readonly ServiceProvider _localDbProvider;
|
||||
private readonly ILocalDb _localDb;
|
||||
|
||||
public DriverHostActorDbLessTests()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
||||
.Build();
|
||||
|
||||
_localDbProvider = new ServiceCollection()
|
||||
.AddZbLocalDb(configuration, db =>
|
||||
{
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
}
|
||||
|
||||
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
|
||||
})
|
||||
.BuildServiceProvider();
|
||||
|
||||
_localDb = _localDbProvider.GetRequiredService<ILocalDb>();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (!disposing) return;
|
||||
|
||||
_localDbProvider.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in.</summary>
|
||||
private static byte[] ArtifactBytes() =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty<object>() });
|
||||
|
||||
private LocalDbAlarmConditionStateStore NewAlarmStore()
|
||||
=> new(_localDb, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
|
||||
|
||||
private static ScriptRootLogger NewScriptRootLogger()
|
||||
=> new(new LoggerConfiguration().CreateLogger());
|
||||
|
||||
/// <summary>Resolves the named child of <paramref name="parent"/>, or null when it was not spawned.
|
||||
/// Safe to call once the parent has replied to an Identify — PreStart (and thus the spawn decision)
|
||||
/// completes before any message is processed.</summary>
|
||||
private IActorRef? ResolveChild(IActorRef parent, string name)
|
||||
{
|
||||
var id = Sys.ActorSelection(parent.Path / name)
|
||||
.Ask<ActorIdentity>(new Identify(name), TimeSpan.FromSeconds(3))
|
||||
.GetAwaiter().GetResult();
|
||||
return id.Subject;
|
||||
}
|
||||
|
||||
/// <summary>Blocks until the actor has processed a message — proving PreStart has run.</summary>
|
||||
private void AwaitStarted(IActorRef actor)
|
||||
{
|
||||
var id = actor.Ask<ActorIdentity>(new Identify("started"), TimeSpan.FromSeconds(5))
|
||||
.GetAwaiter().GetResult();
|
||||
id.Subject.ShouldBe(actor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 4 — a full dispatch on a DB-less actor (null <c>dbFactory</c>, FetchAndCache, LocalDb
|
||||
/// alarm store) applies and acks Applied without a NullReferenceException. The redundant SQL
|
||||
/// ack-writes are guarded to no-ops; central is the ack system of record.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DbLessDispatch_AppliesAndAcks_WithNoConfigDb()
|
||||
{
|
||||
var cache = new RecordingArtifactCache();
|
||||
var fetcher = new RecordingFetcher(ArtifactBytes());
|
||||
var deploymentId = DeploymentId.NewId();
|
||||
var publish = CreateTestProbe();
|
||||
var coordinator = CreateTestProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: null,
|
||||
TestNode,
|
||||
coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
opcUaPublishActor: publish.Ref,
|
||||
scriptRootLogger: NewScriptRootLogger(),
|
||||
deploymentArtifactCache: cache,
|
||||
fetchAndCacheMode: true,
|
||||
artifactFetcher: fetcher,
|
||||
alarmStateStore: NewAlarmStore()));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
|
||||
fetcher.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 6 — a DB-less actor with a LocalDb alarm store spawns its ScriptedAlarm host (the store
|
||||
/// is what makes the spawn possible without a ConfigDb).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DbLessNode_WithLocalDbStore_SpawnsScriptedAlarmHost()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: null,
|
||||
TestNode,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
scriptRootLogger: NewScriptRootLogger(),
|
||||
fetchAndCacheMode: true,
|
||||
artifactFetcher: new RecordingFetcher(ArtifactBytes()),
|
||||
alarmStateStore: NewAlarmStore()));
|
||||
|
||||
AwaitStarted(actor);
|
||||
ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 6 — with neither a LocalDb store NOR a ConfigDb factory there is nowhere to persist
|
||||
/// condition state, so the ScriptedAlarm host is skipped (not spawned against a null store).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DbLessNode_WithNoStoreAndNoDbFactory_SkipsScriptedAlarmHost()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: null,
|
||||
TestNode,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
scriptRootLogger: NewScriptRootLogger(),
|
||||
fetchAndCacheMode: true,
|
||||
artifactFetcher: new RecordingFetcher(ArtifactBytes()),
|
||||
alarmStateStore: null));
|
||||
|
||||
AwaitStarted(actor);
|
||||
ResolveChild(actor, "scripted-alarm-host").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression — a DB-backed actor (non-null <c>dbFactory</c>, no alarm store passed) still spawns
|
||||
/// the host on the EF-store fallback path, exactly as before Phase 4.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DbBackedNode_WithNoStore_StillSpawnsScriptedAlarmHost_OnEfFallback()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: NewInMemoryDbFactory(),
|
||||
TestNode,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
scriptRootLogger: NewScriptRootLogger(),
|
||||
alarmStateStore: null));
|
||||
|
||||
AwaitStarted(actor);
|
||||
ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focused proof the LocalDb alarm store round-trips condition state with NO ConfigDb in play.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LocalDbAlarmStore_RoundTripsConditionState_WithoutConfigDb()
|
||||
{
|
||||
var store = NewAlarmStore();
|
||||
var t = new DateTime(2026, 07, 22, 08, 00, 00, DateTimeKind.Utc);
|
||||
var state = new AlarmConditionState(
|
||||
AlarmId: "dbless-alarm",
|
||||
Enabled: AlarmEnabledState.Enabled,
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: AlarmAckedState.Acknowledged,
|
||||
Confirmed: AlarmConfirmedState.Unconfirmed,
|
||||
Shelving: ShelvingState.Unshelved,
|
||||
LastTransitionUtc: t,
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: t,
|
||||
LastAckUser: "op",
|
||||
LastAckComment: "ack",
|
||||
LastConfirmUtc: null,
|
||||
LastConfirmUser: null,
|
||||
LastConfirmComment: null,
|
||||
Comments: System.Collections.Immutable.ImmutableList<AlarmComment>.Empty);
|
||||
|
||||
await store.SaveAsync(state, CancellationToken.None);
|
||||
var loaded = await store.LoadAsync("dbless-alarm", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
loaded.LastAckUser.ShouldBe("op");
|
||||
}
|
||||
|
||||
/// <summary>Records fetch calls and returns canned bytes (or null for "no answer").</summary>
|
||||
private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher
|
||||
{
|
||||
private int _calls;
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public Task<byte[]?> FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every store; GetCurrent* return null (no idempotency shortcut in these tests).</summary>
|
||||
private sealed class RecordingArtifactCache : IDeploymentArtifactCache
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly List<StoreCall> _stores = [];
|
||||
|
||||
public IReadOnlyList<StoreCall> Stores
|
||||
{
|
||||
get { lock (_gate) { return _stores.ToArray(); } }
|
||||
}
|
||||
|
||||
public Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
|
||||
byte[] artifact, CancellationToken ct = default)
|
||||
{
|
||||
lock (_gate) { _stores.Add(new StoreCall(clusterId, deploymentId, revisionHash, artifact)); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default)
|
||||
=> Task.FromResult<CachedDeploymentArtifact?>(null);
|
||||
|
||||
public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<CachedDeploymentArtifact?>(null);
|
||||
|
||||
internal sealed record StoreCall(
|
||||
string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact);
|
||||
}
|
||||
}
|
||||
+39
@@ -121,6 +121,45 @@ public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
|
||||
loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
|
||||
loaded.LastConfirmUser.ShouldBe("bob");
|
||||
loaded.LastConfirmComment.ShouldBe("confirm-comment");
|
||||
// Shouldly's DateTime ShouldBe compares ticks and ignores DateTimeKind, so an explicit Kind
|
||||
// assertion is the only guard on the Utc-kind contract the round-trip ("O") format encodes.
|
||||
loaded.LastTransitionUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>Unset audit fields (LastAckUtc/User, LastConfirmUtc/User) round-trip back as null.</summary>
|
||||
[Fact]
|
||||
public async Task Null_audit_fields_round_trip_as_null()
|
||||
{
|
||||
var store = NewStore();
|
||||
var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
||||
var state = new AlarmConditionState(
|
||||
AlarmId: "alarm-null-audit",
|
||||
Enabled: AlarmEnabledState.Enabled,
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: AlarmAckedState.Unacknowledged,
|
||||
Confirmed: AlarmConfirmedState.Unconfirmed,
|
||||
Shelving: ShelvingState.Unshelved,
|
||||
LastTransitionUtc: t,
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: null,
|
||||
LastAckUser: null,
|
||||
LastAckComment: null,
|
||||
LastConfirmUtc: null,
|
||||
LastConfirmUser: null,
|
||||
LastConfirmComment: null,
|
||||
Comments: ImmutableList<AlarmComment>.Empty);
|
||||
|
||||
await store.SaveAsync(state, CancellationToken.None);
|
||||
var loaded = await store.LoadAsync("alarm-null-audit", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.LastAckUtc.ShouldBeNull();
|
||||
loaded.LastAckUser.ShouldBeNull();
|
||||
loaded.LastAckComment.ShouldBeNull();
|
||||
loaded.LastConfirmUtc.ShouldBeNull();
|
||||
loaded.LastConfirmUser.ShouldBeNull();
|
||||
loaded.LastConfirmComment.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Loading an id that was never saved returns null.</summary>
|
||||
|
||||
Reference in New Issue
Block a user