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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user