From d9071607474c84ea4d238833a456a6ed8fe47bf4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 12:39:41 -0400 Subject: [PATCH] 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 --- ...h-phase4-cut-driver-configdb.md.tasks.json | 4 +- .../Configuration/LocalDbRegistration.cs | 9 + .../Drivers/DriverHostActor.cs | 122 +++++++- .../ServiceCollectionExtensions.cs | 24 +- .../LocalDbWiringTests.cs | 16 ++ .../Drivers/DriverHostActorDbLessTests.cs | 272 ++++++++++++++++++ .../LocalDbAlarmConditionStateStoreTests.cs | 39 +++ 7 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs diff --git a/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json b/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json index 03e3fb5b..f5228e97 100644 --- a/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json +++ b/docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md.tasks.json @@ -54,7 +54,9 @@ { "id": 5, "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, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs index aa7522ec..f8a54971 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs @@ -2,7 +2,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.LocalDb; 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.ScriptedAlarms; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; @@ -96,6 +98,13 @@ public static class LocalDbRegistration // replicating, and never written to. services.AddSingleton(); + // 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(); + return services; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index e7065ef5..0c310cc0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -97,7 +97,28 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// private bool _isRunningFromCache; - private readonly IDbContextFactory _dbFactory; + /// 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. + private bool _loggedNoConfigDbAck; + + /// + /// Config database context factory, or 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. + /// + private readonly IDbContextFactory? _dbFactory; + + /// + /// Scripted-alarm condition-state store handed to the ScriptedAlarm engine, or + /// when none was wired (per-cluster mesh Phase 4). On a driver-role node + /// this is the replicated LocalDb store (LocalDbAlarmConditionStateStore) so condition + /// state survives without central SQL; when null the actor falls back to an + /// over (legacy admin/Direct + + /// test harnesses), or — when there is no ConfigDb either — skips spawning the alarm host. + /// + private readonly IAlarmStateStore? _alarmStateStore; + private readonly CommonsNodeId _localNode; private readonly IActorRef? _ackRouter; private readonly IDriverFactory _driverFactory; @@ -375,9 +396,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// Optional Phase 6.1 resilience-invoker factory used to attach an /// to each spawned driver; defaults to /// (pass-through) when not supplied. + /// 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 over + /// (legacy admin/Direct + test harnesses), or skips the alarm host when + /// there is no ConfigDb either. Defaults to null. /// The Akka.NET used to spawn this actor. public static Props Props( - IDbContextFactory dbFactory, + IDbContextFactory? dbFactory, CommonsNodeId localNode, IActorRef? ackRouter = null, IDriverFactory? driverFactory = null, @@ -397,7 +423,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers IRedundancyRoleView? redundancyRoleView = null, string? replicationPeerHost = null, 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 // 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 @@ -407,7 +434,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor, healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride, loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider, - deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher)); + deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher, + alarmStateStore)); /// Initializes a new DriverHostActor with the specified dependencies. /// Database context factory for configuration database access. @@ -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 /// 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. + /// 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 + /// over , or skips the alarm + /// host when there is no ConfigDb either. public DriverHostActor( - IDbContextFactory dbFactory, + IDbContextFactory? dbFactory, CommonsNodeId localNode, IActorRef? ackRouter, IDriverFactory? driverFactory = null, @@ -463,13 +495,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers IRedundancyRoleView? redundancyRoleView = null, string? replicationPeerHost = null, bool fetchAndCacheMode = false, - IDeploymentArtifactFetcher? artifactFetcher = null) + IDeploymentArtifactFetcher? artifactFetcher = null, + IAlarmStateStore? alarmStateStore = null) { _deploymentArtifactCache = deploymentArtifactCache; _redundancyRoleView = redundancyRoleView; _replicationPeerHost = replicationPeerHost; _fetchAndCacheMode = fetchAndCacheMode; _artifactFetcher = artifactFetcher; + _alarmStateStore = alarmStateStore; _dbFactory = dbFactory; _localNode = localNode; _ackRouter = ackRouter; @@ -583,9 +617,31 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } + // Per-cluster mesh Phase 4: prefer the wired condition-state store (the replicated LocalDb store + // 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()); + } + else + { + _log.Debug( + "DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store and no ConfigDb)", + _localNode); + return; + } + var upstream = new DependencyMuxTagUpstreamSource(); - var store = new EfAlarmConditionStateStore( - _dbFactory, _loggerFactory.CreateLogger()); var engine = new ScriptedAlarmEngine( upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger); _scriptedAlarmHost = Context.ActorOf( @@ -603,6 +659,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers 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 // 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. @@ -1914,6 +1981,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// 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; try { @@ -2090,6 +2166,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// 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; try { @@ -2575,8 +2659,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers { // 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 - // central SQL. If we somehow land here, leave Steady rather than re-reading Deployments. - if (_fetchAndCacheMode) + // central SQL. A DB-less node (null factory, Phase 4) likewise has nothing to recover from. If + // we somehow land here, leave Steady rather than re-reading Deployments. + if (_fetchAndCacheMode || _dbFactory is null) { Timers.Cancel("retry-db"); Become(Steady); @@ -2612,6 +2697,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers 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 { using var db = _dbFactory.CreateDbContext(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 5a8ae862..934f8ef1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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.VirtualTags; 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 // instead of pretending to cache into a sink that drops everything. var deploymentArtifactCache = resolver.GetService(); + // 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(); // 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; // 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 // the boundary; under Dps it stays null and the host publishes on the // deployment-acks topic exactly as before. - // dbFactory! — KNOWN INTERIM. A driver-only node genuinely passes a null factory here - // (ConfigDb is now gated on hasAdmin, and driver-only nodes are forced to FetchAndCache), - // so DriverHostActor's ConfigDb-touching paths — scripted-alarm state persistence - // (EfAlarmConditionStateStore) and UpsertNodeDeploymentState — fault-and-swallow until - // Task 4 makes the factory nullable + guards those sites (and removes this !) and Task 6 - // re-homes the alarm store to LocalDb. Inert in practice: no driver-only node is - // configured to boot until the Task 10 rig change. - DriverHostActor.Props(dbFactory!, roleInfo.LocalNode, + // dbFactory is nullable (Phase 4): a driver-only node passes null here — ConfigDb is + // gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor + // guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central + // persists acks from the ApplyAck) and serves scripted-alarm state from the LocalDb + // alarmStateStore instead of an EfAlarmConditionStateStore. + DriverHostActor.Props(dbFactory, roleInfo.LocalNode, ackRouter: useClusterClient ? nodeComm : null, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, dependencyMux: mux, @@ -484,7 +489,8 @@ public static class ServiceCollectionExtensions redundancyRoleView: redundancyRoleView, replicationPeerHost: replicationPeerHost, fetchAndCacheMode: fetchAndCacheMode, - artifactFetcher: artifactFetcher), + artifactFetcher: artifactFetcher, + alarmStateStore: alarmStateStore), DriverHostActorName); registry.Register(driverHost); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs index a2c81f5a..4dc9bd56 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs @@ -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(); } + [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() + .ShouldBeOfType(); + } + [Fact] public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer() { @@ -142,6 +156,8 @@ public sealed class LocalDbWiringTests : IDisposable sp.GetService().ShouldBeNull(); sp.GetService().ShouldBeNull(); + // Phase 4: an admin-only node never registers the LocalDb alarm store either. + sp.GetService().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. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs new file mode 100644 index 00000000..dc5177ab --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs @@ -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; + +/// +/// Per-cluster mesh Phase 4 — a driver-only node runs with NO ConfigDb: dbFactory is null, +/// the redundant SQL ack-writes no-op (central persists the ack from the ApplyAck), and +/// scripted-alarm condition state is served from the replicated LocalDb store instead of the +/// ConfigDb-backed EF store. These tests drive a FetchAndCache actor with a null +/// dbFactory — the shape a real driver-only node boots in. +/// +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 { ["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(); + } + + 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 */ } + } + } + + /// Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in. + private static byte[] ArtifactBytes() => + JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty() }); + + private LocalDbAlarmConditionStateStore NewAlarmStore() + => new(_localDb, NullLogger.Instance); + + private static ScriptRootLogger NewScriptRootLogger() + => new(new LoggerConfiguration().CreateLogger()); + + /// Resolves the named child of , 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. + private IActorRef? ResolveChild(IActorRef parent, string name) + { + var id = Sys.ActorSelection(parent.Path / name) + .Ask(new Identify(name), TimeSpan.FromSeconds(3)) + .GetAwaiter().GetResult(); + return id.Subject; + } + + /// Blocks until the actor has processed a message — proving PreStart has run. + private void AwaitStarted(IActorRef actor) + { + var id = actor.Ask(new Identify("started"), TimeSpan.FromSeconds(5)) + .GetAwaiter().GetResult(); + id.Subject.ShouldBe(actor); + } + + /// + /// Task 4 — a full dispatch on a DB-less actor (null dbFactory, 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. + /// + [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 { "driver" }, + opcUaPublishActor: publish.Ref, + scriptRootLogger: NewScriptRootLogger(), + deploymentArtifactCache: cache, + fetchAndCacheMode: true, + artifactFetcher: fetcher, + alarmStateStore: NewAlarmStore())); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); + AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3)); + fetcher.Calls.ShouldBe(1); + } + + /// + /// 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). + /// + [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(); + } + + /// + /// 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). + /// + [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(); + } + + /// + /// Regression — a DB-backed actor (non-null dbFactory, no alarm store passed) still spawns + /// the host on the EF-store fallback path, exactly as before Phase 4. + /// + [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(); + } + + /// + /// Focused proof the LocalDb alarm store round-trips condition state with NO ConfigDb in play. + /// + [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.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"); + } + + /// Records fetch calls and returns canned bytes (or null for "no answer"). + private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher + { + private int _calls; + public int Calls => Volatile.Read(ref _calls); + + public Task FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct) + { + Interlocked.Increment(ref _calls); + return Task.FromResult(bytes); + } + } + + /// Records every store; GetCurrent* return null (no idempotency shortcut in these tests). + private sealed class RecordingArtifactCache : IDeploymentArtifactCache + { + private readonly Lock _gate = new(); + private readonly List _stores = []; + + public IReadOnlyList 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 GetCurrentAsync(string clusterId, CancellationToken ct = default) + => Task.FromResult(null); + + public Task GetCurrentUnkeyedAsync(CancellationToken ct = default) + => Task.FromResult(null); + + internal sealed record StoreCall( + string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs index b2c15696..fc0d9525 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs @@ -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); + } + + /// Unset audit fields (LastAckUtc/User, LastConfirmUtc/User) round-trip back as null. + [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.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(); } /// Loading an id that was never saved returns null.