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:
Joseph Doherty
2026-07-23 12:39:41 -04:00
parent 4f4cdd05ec
commit d907160747
7 changed files with 466 additions and 20 deletions
@@ -54,7 +54,9 @@
{ {
"id": 5, "id": 5,
"subject": "Task 5: alarm_condition_state LocalDb table + LocalDbAlarmConditionStateStore", "subject": "Task 5: alarm_condition_state LocalDb table + LocalDbAlarmConditionStateStore",
"status": "pending" "status": "completed",
"commit": "4f4cdd05",
"result": "16 new tests; SPEC-COMPLIANT + APPROVED (ON CONFLICT DO UPDATE replication-safe). Fold Kind+null-audit test polish into Task 4/6"
}, },
{ {
"id": 6, "id": 6,
@@ -2,7 +2,9 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
@@ -96,6 +98,13 @@ public static class LocalDbRegistration
// replicating, and never written to. // replicating, and never written to.
services.AddSingleton<IDeploymentArtifactCache, LocalDbDeploymentArtifactCache>(); services.AddSingleton<IDeploymentArtifactCache, LocalDbDeploymentArtifactCache>();
// Per-cluster mesh Phase 4: scripted-alarm condition state served from the replicated LocalDb
// store instead of the ConfigDb-backed EF store, so a driver-only node (no ConfigDb) keeps its
// Part 9 condition state and mirrors it to its pair peer. Driver-role only, alongside the cache —
// admin-only nodes never register LocalDb. WithOtOpcUaRuntimeActors resolves it optionally and
// threads it into DriverHostActor's ScriptedAlarm engine. Singleton to match ILocalDb + the cache.
services.AddSingleton<IAlarmStateStore, LocalDbAlarmConditionStateStore>();
return services; return services;
} }
} }
@@ -97,7 +97,28 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </remarks> /// </remarks>
private bool _isRunningFromCache; private bool _isRunningFromCache;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory; /// <summary>Latches after the first no-op ack-write on a DB-less node so the explanatory Debug line
/// is logged once, not on every apply state transition.</summary>
private bool _loggedNoConfigDbAck;
/// <summary>
/// Config database context factory, or <see langword="null"/> on a driver-only node (per-cluster
/// mesh Phase 4). A driver-only node has NO ConfigDb — LocalDb is its config store and central is
/// the ack system of record — so every ConfigDb-touching path here is guarded on this being
/// non-null. Non-null on a fused admin+driver node and in the Direct-mode/EF test harnesses.
/// </summary>
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
/// <summary>
/// Scripted-alarm condition-state store handed to the ScriptedAlarm engine, or
/// <see langword="null"/> when none was wired (per-cluster mesh Phase 4). On a driver-role node
/// this is the replicated LocalDb store (<c>LocalDbAlarmConditionStateStore</c>) so condition
/// state survives without central SQL; when null the actor falls back to an
/// <see cref="EfAlarmConditionStateStore"/> over <see cref="_dbFactory"/> (legacy admin/Direct +
/// test harnesses), or — when there is no ConfigDb either — skips spawning the alarm host.
/// </summary>
private readonly IAlarmStateStore? _alarmStateStore;
private readonly CommonsNodeId _localNode; private readonly CommonsNodeId _localNode;
private readonly IActorRef? _ackRouter; private readonly IActorRef? _ackRouter;
private readonly IDriverFactory _driverFactory; private readonly IDriverFactory _driverFactory;
@@ -375,9 +396,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="invokerFactory">Optional Phase 6.1 resilience-invoker factory used to attach an /// <param name="invokerFactory">Optional Phase 6.1 resilience-invoker factory used to attach an
/// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to /// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to
/// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when not supplied.</param> /// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when not supplied.</param>
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
/// driver-role node this is the replicated LocalDb store, so condition state persists with no
/// ConfigDb; null falls back to an <see cref="EfAlarmConditionStateStore"/> over
/// <paramref name="dbFactory"/> (legacy admin/Direct + test harnesses), or skips the alarm host when
/// there is no ConfigDb either. Defaults to null.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns> /// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props( public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode, CommonsNodeId localNode,
IActorRef? ackRouter = null, IActorRef? ackRouter = null,
IDriverFactory? driverFactory = null, IDriverFactory? driverFactory = null,
@@ -397,7 +423,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IRedundancyRoleView? redundancyRoleView = null, IRedundancyRoleView? redundancyRoleView = null,
string? replicationPeerHost = null, string? replicationPeerHost = null,
bool fetchAndCacheMode = false, bool fetchAndCacheMode = false,
IDeploymentArtifactFetcher? artifactFetcher = null) => IDeploymentArtifactFetcher? artifactFetcher = null,
IAlarmStateStore? alarmStateStore = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an // WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a // expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds // mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -407,7 +434,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor, dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride, healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider, loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher)); deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary> /// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param> /// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -442,8 +470,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// When supplied, each successful apply stores its artifact so the node can boot from its /// When supplied, each successful apply stores its artifact so the node can boot from its
/// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in /// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in
/// tests that do not exercise the cache — caching is then simply skipped.</param> /// tests that do not exercise the cache — caching is then simply skipped.</param>
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
/// driver-role node this is the replicated LocalDb store; null falls back to an
/// <see cref="EfAlarmConditionStateStore"/> over <paramref name="dbFactory"/>, or skips the alarm
/// host when there is no ConfigDb either.</param>
public DriverHostActor( public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode, CommonsNodeId localNode,
IActorRef? ackRouter, IActorRef? ackRouter,
IDriverFactory? driverFactory = null, IDriverFactory? driverFactory = null,
@@ -463,13 +495,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IRedundancyRoleView? redundancyRoleView = null, IRedundancyRoleView? redundancyRoleView = null,
string? replicationPeerHost = null, string? replicationPeerHost = null,
bool fetchAndCacheMode = false, bool fetchAndCacheMode = false,
IDeploymentArtifactFetcher? artifactFetcher = null) IDeploymentArtifactFetcher? artifactFetcher = null,
IAlarmStateStore? alarmStateStore = null)
{ {
_deploymentArtifactCache = deploymentArtifactCache; _deploymentArtifactCache = deploymentArtifactCache;
_redundancyRoleView = redundancyRoleView; _redundancyRoleView = redundancyRoleView;
_replicationPeerHost = replicationPeerHost; _replicationPeerHost = replicationPeerHost;
_fetchAndCacheMode = fetchAndCacheMode; _fetchAndCacheMode = fetchAndCacheMode;
_artifactFetcher = artifactFetcher; _artifactFetcher = artifactFetcher;
_alarmStateStore = alarmStateStore;
_dbFactory = dbFactory; _dbFactory = dbFactory;
_localNode = localNode; _localNode = localNode;
_ackRouter = ackRouter; _ackRouter = ackRouter;
@@ -583,9 +617,31 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
var upstream = new DependencyMuxTagUpstreamSource(); // Per-cluster mesh Phase 4: prefer the wired condition-state store (the replicated LocalDb store
var store = new EfAlarmConditionStateStore( // on a driver-role node — it persists with no ConfigDb). Only when no store was wired do we fall
// back to the ConfigDb-backed EF store, and only if there IS a ConfigDb (legacy admin/Direct +
// test harnesses). A driver-only node has neither — nowhere to persist condition state — so it
// skips the alarm host outright rather than constructing an EF store over a null factory that
// would NRE the moment the engine touched it (mirrors the _opcUaPublishActor-null skip above).
IAlarmStateStore store;
if (_alarmStateStore is not null)
{
store = _alarmStateStore;
}
else if (_dbFactory is not null)
{
store = new EfAlarmConditionStateStore(
_dbFactory, _loggerFactory.CreateLogger<EfAlarmConditionStateStore>()); _dbFactory, _loggerFactory.CreateLogger<EfAlarmConditionStateStore>());
}
else
{
_log.Debug(
"DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store and no ConfigDb)",
_localNode);
return;
}
var upstream = new DependencyMuxTagUpstreamSource();
var engine = new ScriptedAlarmEngine( var engine = new ScriptedAlarmEngine(
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger); upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
_scriptedAlarmHost = Context.ActorOf( _scriptedAlarmHost = Context.ActorOf(
@@ -603,6 +659,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
// Belt-and-suspenders (Phase 4): every ConfigDb read below is on the Direct path only — a
// FetchAndCache node already returned via BootstrapFromCache above, and only a FetchAndCache node
// runs DB-less. If a null factory ever reaches a Direct path it is a misconfiguration; enter
// Steady with no revision rather than NRE, and let the first dispatch drive the apply.
if (_dbFactory is null)
{
_log.Info("DriverHost {Node}: no ConfigDb on the Direct bootstrap path; entering Steady (no revision)", _localNode);
Become(Steady);
return;
}
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump // Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
// to Steady with that revision. If Applying (orphan from a crash), discard and replay. // to Steady with that revision. If Applying (orphan from a crash), discard and replay.
// If the DB is unreachable, fall back to Stale and start the reconnect loop. // If the DB is unreachable, fall back to Stale and start the reconnect loop.
@@ -1914,6 +1981,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </remarks> /// </remarks>
private byte[]? ReconcileDrivers(DeploymentId deploymentId) private byte[]? ReconcileDrivers(DeploymentId deploymentId)
{ {
// Phase 4 belt-and-suspenders: this ConfigDb read is Direct-only (the FetchAndCache apply uses
// ReconcileDriversFromBlob with bytes in hand). A null factory can't read, so return null — the
// caller treats that as "artifact could not be read" (#486) and keeps last-known-good.
if (_dbFactory is null)
{
_log.Warning("DriverHost {Node}: no ConfigDb to load artifact for {Id}; skipping reconcile", _localNode, deploymentId);
return null;
}
byte[] blob; byte[] blob;
try try
{ {
@@ -2090,6 +2166,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary> /// </summary>
private void PushDesiredSubscriptions(DeploymentId deploymentId) private void PushDesiredSubscriptions(DeploymentId deploymentId)
{ {
// Phase 4 belt-and-suspenders: Direct-only read (the FetchAndCache path calls
// PushDesiredSubscriptionsFromArtifact with bytes in hand). No ConfigDb ⇒ nothing to load.
if (_dbFactory is null)
{
_log.Warning("DriverHost {Node}: no ConfigDb to load artifact for SubscribeBulk ({Id}); skipping", _localNode, deploymentId);
return;
}
byte[] blob; byte[] blob;
try try
{ {
@@ -2575,8 +2659,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{ {
// Defensive: FetchAndCache never enters Stale (BootstrapFromCache always Becomes Steady, so // Defensive: FetchAndCache never enters Stale (BootstrapFromCache always Becomes Steady, so
// the retry-db timer that drives this is never started), and its recovery must NOT read // the retry-db timer that drives this is never started), and its recovery must NOT read
// central SQL. If we somehow land here, leave Steady rather than re-reading Deployments. // central SQL. A DB-less node (null factory, Phase 4) likewise has nothing to recover from. If
if (_fetchAndCacheMode) // we somehow land here, leave Steady rather than re-reading Deployments.
if (_fetchAndCacheMode || _dbFactory is null)
{ {
Timers.Cancel("retry-db"); Timers.Cancel("retry-db");
Become(Steady); Become(Steady);
@@ -2612,6 +2697,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private void UpsertNodeDeploymentState(DeploymentId deploymentId, NodeDeploymentStatus status, string? failureReason) private void UpsertNodeDeploymentState(DeploymentId deploymentId, NodeDeploymentStatus status, string? failureReason)
{ {
// Per-cluster mesh Phase 4: a driver-only node has NO ConfigDb. Central is the ack system of
// record — ConfigPublishCoordinator.PersistNodeAck already writes NodeDeploymentState from every
// ApplyAck it receives (and seeds the Applying rows at dispatch) — so this node's own upsert is
// redundant and simply no-ops here with no loss of information. Logged once to keep the reason
// discoverable without spamming every apply transition.
if (_dbFactory is null)
{
if (!_loggedNoConfigDbAck)
{
_loggedNoConfigDbAck = true;
_log.Debug(
"DriverHost {Node}: no ConfigDb — NodeDeploymentState written by central from the ApplyAck",
_localNode);
}
return;
}
try try
{ {
using var db = _dbFactory.CreateDbContext(); using var db = _dbFactory.CreateDbContext();
@@ -18,6 +18,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; 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.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -282,6 +283,12 @@ public static class ServiceCollectionExtensions
// harnesses) rather than given a null-object, so DriverHostActor skips caching outright // harnesses) rather than given a null-object, so DriverHostActor skips caching outright
// instead of pretending to cache into a sink that drops everything. // instead of pretending to cache into a sink that drops everything.
var deploymentArtifactCache = resolver.GetService<IDeploymentArtifactCache>(); var deploymentArtifactCache = resolver.GetService<IDeploymentArtifactCache>();
// Per-cluster mesh Phase 4: scripted-alarm condition-state store. Registered by the Host's
// AddOtOpcUaLocalDb on driver-role nodes (the replicated LocalDb store), so condition state
// persists with no ConfigDb; null elsewhere (admin-only graphs, test harnesses) → the actor
// falls back to an EfAlarmConditionStateStore over dbFactory, or skips the alarm host when
// there is no ConfigDb either.
var alarmStateStore = resolver.GetService<IAlarmStateStore>();
// Per-cluster mesh Phase 3: ConfigSource:Mode selects where this driver reads config. // Per-cluster mesh Phase 3: ConfigSource:Mode selects where this driver reads config.
// FetchAndCache pulls the artifact from central over gRPC and reads only the LocalDb cache; // FetchAndCache pulls the artifact from central over gRPC and reads only the LocalDb cache;
// Direct (default) reads central SQL as before. The fetcher is resolved only in FetchAndCache // Direct (default) reads central SQL as before. The fetcher is resolved only in FetchAndCache
@@ -462,14 +469,12 @@ public static class ServiceCollectionExtensions
// ackRouter: under ClusterClient mode the node comm actor relays ApplyAcks across // ackRouter: under ClusterClient mode the node comm actor relays ApplyAcks across
// the boundary; under Dps it stays null and the host publishes on the // the boundary; under Dps it stays null and the host publishes on the
// deployment-acks topic exactly as before. // deployment-acks topic exactly as before.
// dbFactory! — KNOWN INTERIM. A driver-only node genuinely passes a null factory here // dbFactory is nullable (Phase 4): a driver-only node passes null here — ConfigDb is
// (ConfigDb is now gated on hasAdmin, and driver-only nodes are forced to FetchAndCache), // gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor
// so DriverHostActor's ConfigDb-touching paths — scripted-alarm state persistence // guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central
// (EfAlarmConditionStateStore) and UpsertNodeDeploymentState — fault-and-swallow until // persists acks from the ApplyAck) and serves scripted-alarm state from the LocalDb
// Task 4 makes the factory nullable + guards those sites (and removes this !) and Task 6 // alarmStateStore instead of an EfAlarmConditionStateStore.
// re-homes the alarm store to LocalDb. Inert in practice: no driver-only node is DriverHostActor.Props(dbFactory, roleInfo.LocalNode,
// configured to boot until the Task 10 rig change.
DriverHostActor.Props(dbFactory!, roleInfo.LocalNode,
ackRouter: useClusterClient ? nodeComm : null, ackRouter: useClusterClient ? nodeComm : null,
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
dependencyMux: mux, dependencyMux: mux,
@@ -484,7 +489,8 @@ public static class ServiceCollectionExtensions
redundancyRoleView: redundancyRoleView, redundancyRoleView: redundancyRoleView,
replicationPeerHost: replicationPeerHost, replicationPeerHost: replicationPeerHost,
fetchAndCacheMode: fetchAndCacheMode, fetchAndCacheMode: fetchAndCacheMode,
artifactFetcher: artifactFetcher), artifactFetcher: artifactFetcher,
alarmStateStore: alarmStateStore),
DriverHostActorName); DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost); registry.Register<DriverHostActorKey>(driverHost);
@@ -11,7 +11,9 @@ using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.Observability; 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.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -107,6 +109,18 @@ public sealed class LocalDbWiringTests : IDisposable
.ShouldBeOfType<LocalDbDeploymentArtifactCache>(); .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] [Fact]
public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer() public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer()
{ {
@@ -142,6 +156,8 @@ public sealed class LocalDbWiringTests : IDisposable
sp.GetService<ILocalDb>().ShouldBeNull(); sp.GetService<ILocalDb>().ShouldBeNull();
sp.GetService<IDeploymentArtifactCache>().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 // 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. // 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);
}
}
@@ -121,6 +121,45 @@ public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc); loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
loaded.LastConfirmUser.ShouldBe("bob"); loaded.LastConfirmUser.ShouldBe("bob");
loaded.LastConfirmComment.ShouldBe("confirm-comment"); 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> /// <summary>Loading an id that was never saved returns null.</summary>