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 6c8a1aea..af23653e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -126,6 +126,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// value variables, so they are kept OUT of the value maps + value-subscription set. private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet> _alarmNodeIdByDriverRef = new(); + /// + /// Inverse of : folder-scoped condition NodeId → + /// (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference). Built in the SAME apply + /// pass from the alarm-bearing EquipmentTags, and resolved by + /// so an inbound OPC UA acknowledge of a native condition (resolved to the condition's NodeId by the + /// node manager) is forwarded to the owning driver child as an acknowledge of its wire-ref + /// FullName. Each condition NodeId maps to exactly one driver ref (a condition is backed by a + /// single driver alarm), so this is a flat 1:1 map (the forward map fans out 1:N because one ref can + /// back several conditions on identical machines). + /// + private readonly Dictionary _driverRefByAlarmNodeId = + new(StringComparer.Ordinal); + /// Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type) for building the /// AlarmTransitionEvent fan-out. Built in the same PushDesiredSubscriptions alarm branch. private readonly Dictionary _alarmMetaByNodeId = @@ -168,6 +181,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// Failure detail when is false; null on success. public sealed record NodeWriteResult(bool Success, string? Reason); + /// + /// Routes an inbound native-condition acknowledge to the owning driver child. The host wires the + /// OPC UA node manager's NativeAlarmAckRouter to Tell this in when a client Acknowledges a + /// NATIVE Part 9 condition; applies the same Primary gate the + /// inbound write path uses, resolves the inverse map, and Tells + /// the owning a + /// carrying the principal — fire-and-forget (the Part 9 ack already committed the local condition + /// state). Deliberately decoupled from the OpcUaServer NativeAlarmAck type: the host maps + /// NativeAlarmAck → this at the wiring boundary so Runtime does not depend on the OPC UA layer. + /// + /// The folder-scoped condition NodeId the operator acknowledged. + /// Operator-supplied comment forwarded to the upstream alarm system; null when none. + /// The authenticated principal performing the acknowledge. + public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser); + private sealed record ChildEntry(IActorRef Actor, DriverInstanceSpec Spec, bool Stubbed) { // Convenience accessors for sites that don't need the full spec. @@ -454,6 +482,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); + Receive(HandleRouteNativeAlarmAck); Receive(OnRedundancyStateChanged); Receive(_ => { /* PubSub ack */ }); } @@ -478,6 +507,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); + Receive(HandleRouteNativeAlarmAck); Receive(OnRedundancyStateChanged); Receive(_ => { /* PubSub ack */ }); } @@ -650,6 +680,55 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers .PipeTo(replyTo); } + /// + /// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager + /// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors + /// 's order + gating, but the ack is fire-and-forget (no reply): + /// the OPC UA Part 9 acknowledge already committed the local condition state and the driver's + /// returns no per-condition status. + /// + /// PRIMARY gate FIRST — reuses the same redundancy signal as the + /// inbound-write gate. Only the Primary pushes the ack to the shared upstream alarm system + /// (default-allow while the role is unknown). A Secondary/Detached node keeps its condition + /// state warm for failover but must NOT push the command. Drop (debug-log) on non-primary. + /// Resolve the inverse map to the owning + /// (DriverInstanceId, FullName = ConditionId); an unmapped node is debug-logged + dropped + /// (no throw) — mirrors 's unknown-ref drop. + /// Resolve the running driver child and Tell it a + /// carrying the wire-ref FullName + principal. + /// + /// + private void HandleRouteNativeAlarmAck(RouteNativeAlarmAck msg) + { + // PRIMARY GATE FIRST — only the Primary services operator acks (same signal as the inbound-write + + // alarm-emit gates; unknown role ⇒ treated as Primary so single-node deploys + the boot window aren't blocked). + if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) + { + _log.Debug("DriverHost {Node}: dropping native-alarm ack for {Cond} — not primary", + _localNode, msg.ConditionNodeId); + return; + } + + if (!_driverRefByAlarmNodeId.TryGetValue(msg.ConditionNodeId, out var target)) + { + _log.Debug("DriverHost {Node}: no driver mapping for alarm condition node {Cond} — ack dropped", + _localNode, msg.ConditionNodeId); + return; + } + + if (!_children.TryGetValue(target.DriverInstanceId, out var entry)) + { + _log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped", + _localNode, target.DriverInstanceId, msg.ConditionNodeId); + return; + } + + // Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the + // driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The + // driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on). + entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.FullName, msg.Comment, msg.OperatorUser)); + } + /// Caches this node's from a cluster redundancy snapshot so /// can gate inbound writes to the Primary. A snapshot that doesn't /// mention this node leaves the cached role unchanged ⇒ default-allow. Mirrors @@ -678,6 +757,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // node-manager's bounded Ask gets an immediate clear status instead of dead-lettering into a timeout. Receive(_ => Sender.Tell(new NodeWriteResult(false, "driver host stale (config DB unreachable)"))); + // An inbound native-condition ack can't be serviced while Stale either (the alarm inverse map is + // empty until an apply runs). The ack is fire-and-forget (no reply), so just drop it with a log — + // the local OPC UA condition state already committed on the Part 9 ack. + Receive(msg => + _log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)", + _localNode, msg.ConditionNodeId)); Receive(_ => { /* PubSub ack */ }); Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval); } @@ -905,6 +990,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // every apply; the projector is Clear()'d too so stale per-condition state never leaks across // redeploys (renames/removals/address-space rebuilds). _alarmNodeIdByDriverRef.Clear(); + // Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId, + // FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver + // acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME + // alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to + // exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the + // rare duplicate is harmless. + _driverRefByAlarmNodeId.Clear(); // Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in // the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it. _alarmMetaByNodeId.Clear(); @@ -921,6 +1013,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset)) _alarmNodeIdByDriverRef[key] = aset = new HashSet(StringComparer.Ordinal); aset.Add(nodeId); + // Inverse 1:1 map for the inbound native-condition ack path: the materialised condition + // NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA + // acknowledge of this condition reaches the right driver child. + _driverRefByAlarmNodeId[nodeId] = key; // Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build // the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the // OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 1f680dff..062c9c62 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -40,6 +40,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation); public sealed record WriteAttribute(string TagId, object Value); public sealed record WriteAttributeResult(bool Success, string? Reason); + /// + /// Sent by when an OPC UA client Acknowledges a NATIVE Part 9 + /// condition (resolved from the condition NodeId to this driver via the host's alarm inverse map). + /// The actor forwards it to the driver's , carrying the + /// authored alarm full-reference (= the driver's ConditionId/AlarmFullReference) and the + /// authenticated principal. Mirrors , but the ack is fire-and-forget: + /// the driver's returns no per-condition status and the + /// OPC UA Part 9 ack already committed the local condition state, so there is no reply to surface. + /// + /// The authored alarm full-reference the driver correlates the ack on + /// (= the equipment tag's FullName/AlarmFullReference). + /// Operator-supplied comment forwarded to the upstream alarm system; null when none. + /// The authenticated principal performing the acknowledge. + public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser); public sealed record Subscribe(IReadOnlyList FullReferences, TimeSpan PublishingInterval); /// /// Sets the set of references this driver should keep subscribed for the lifetime of the @@ -240,6 +254,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(_ => { /* no-op */ }); Receive(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation))); Receive(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed"))); + // Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply). + Receive(_ => { /* stubbed drivers have no alarm backend */ }); Receive(_ => { /* stubbed drivers don't disconnect */ }); Receive(_ => { /* stubbed drivers don't reconnect */ }); Receive(StoreDesiredSubscriptions); @@ -254,6 +270,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // "write timeout". Synchronous Receive: Sender.Tell on the actor thread is safe (#4a-instance). Receive(_ => Sender.Tell(new WriteAttributeResult(false, "driver not connected"))); + // An ack arriving while still connecting can't reach the upstream alarm system; drop it (the ack is + // fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally). + Receive(_ => + _log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId)); Receive(AdoptConfigDuringInit); Receive(msg => { @@ -314,6 +334,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers PublishHealthSnapshot(); }); ReceiveAsync(HandleWriteAsync); + ReceiveAsync(HandleAcknowledgeAsync); ReceiveAsync(HandleSubscribeAsync); ReceiveAsync(_ => UnsubscribeAsync()); Receive(msg => @@ -354,6 +375,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // timeout on an inbound write to a transiently-down driver). Synchronous Receive (#4a-instance). Receive(_ => Sender.Tell(new WriteAttributeResult(false, "driver not connected"))); + // An ack arriving while reconnecting can't reach the upstream alarm system; drop it (fire-and-forget, + // no reply — the OPC UA condition state already committed locally on the Part 9 ack). + Receive(_ => + _log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId)); Receive(AdoptConfigDuringInit); Receive(msg => { @@ -473,6 +498,46 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } + /// + /// Forwards an inbound native-condition acknowledge (routed by from a + /// resolved condition NodeId) to the driver's . The driver + /// correlates on (= the authored alarm + /// full-reference); carries the same reference (the + /// driver's ack path keys on ConditionId). Bounded to 5s so a hung backend can't pin this actor. + /// Fire-and-forget — the OPC UA Part 9 ack already committed the local condition state and + /// returns no per-condition status — so there is no reply; + /// a failure is logged and dropped (the local condition stays Acknowledged regardless). + /// + private async Task HandleAcknowledgeAsync(RouteAlarmAck msg) + { + if (_driver is not IAlarmSource src) + { + _log.Warning("DriverInstance {Id}: alarm ack dropped — driver does not implement IAlarmSource", _driverInstanceId); + return; + } + + var request = new[] + { + new AlarmAcknowledgeRequest( + SourceNodeId: msg.ConditionId, + ConditionId: msg.ConditionId, + Comment: msg.Comment, + OperatorUser: msg.OperatorUser), + }; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await src.AcknowledgeAsync(request, cts.Token); + _log.Info("DriverInstance {Id}: acknowledged native condition {Cond} by {User}", + _driverInstanceId, msg.ConditionId, msg.OperatorUser); + } + catch (Exception ex) + { + _log.Warning(ex, "DriverInstance {Id}: native-alarm acknowledge of {Cond} failed", + _driverInstanceId, msg.ConditionId); + } + } + private async Task HandleSubscribeAsync(Subscribe msg) { // Capture Sender/Self BEFORE any await. The re-subscribe path below awaits diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs new file mode 100644 index 00000000..a55fe10a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs @@ -0,0 +1,282 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Akka.Actor; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Verifies the inbound native-condition acknowledge routing wired into +/// (H6d): an OPC UA client Acknowledges a NATIVE condition, the +/// node manager invokes NativeAlarmAckRouter, and the host (NEXT task) Tells a +/// in. The host resolves the condition NodeId → +/// owning (DriverInstanceId, FullName) via the _driverRefByAlarmNodeId inverse map +/// (built alongside the alarm forward map in PushDesiredSubscriptions), applies the SAME +/// primary gate the inbound write path uses, and routes to the owning driver child's +/// carrying the principal. +/// +/// +/// Mirrors DriverHostActorWriteRoutingTests: a real apply through the existing harness +/// spawns a real (non-stubbed) child backed by a recording +/// driver, so the inverse map is populated authentically and the +/// forwarded acknowledge request can be observed. The seeded tag carries an alarm object so +/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable. +/// +/// +public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("driver-ack-test"); + private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + /// On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId + /// forwards exactly one to the owning driver's + /// , with ConditionId == FullName, the operator + /// principal, and the comment. + [Fact] + public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal() + { + var db = NewInMemoryDbFactory(); + var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); + // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi" + // → condition NodeId "eq-1/temp_hi". + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); + + var actor = SpawnHostAndApply(db, deploymentId, recorder); + + // Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics). + actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice")); + + // The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal. + AwaitAssert(() => + { + recorder.Acks.Count.ShouldBe(1); + recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi"); + recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi"); + recorder.Acks[0].Comment.ShouldBe("cmt"); + recorder.Acks[0].OperatorUser.ShouldBe("alice"); + }, duration: Timeout); + } + + /// An ack for an unmapped condition NodeId is dropped: no throw, and the driver's + /// is never called. + [Fact] + public void RouteNativeAlarmAck_unknown_node_is_dropped() + { + var db = NewInMemoryDbFactory(); + var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); + + var actor = SpawnHostAndApply(db, deploymentId, recorder); + + actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice")); + + // Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack. + AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); + } + + /// On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the + /// driver's is NOT called — a secondary keeps its address + /// space warm but must not push commands to the shared upstream alarm system. + [Fact] + public void RouteNativeAlarmAck_on_non_primary_is_dropped() + { + var db = NewInMemoryDbFactory(); + var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway"); + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); + + var actor = SpawnHostAndApply(db, deploymentId, recorder); + + // Force this node Secondary so the primary gate rejects the ack. + actor.Tell(new RedundancyStateChanged( + new[] + { + new NodeRedundancyState(TestNode, RedundancyRole.Secondary, + IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + }, + CorrelationId.NewId())); + + actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice")); + + // No ack reached the driver — the gate short-circuited before the inverse-map lookup. + AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); + } + + /// Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits + /// for the Applied ACK so the apply (and thus the inverse-map build in PushDesiredSubscriptions) has + /// completed before the test routes an ack. + private IActorRef SpawnHostAndApply( + IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) + { + var coordinator = CreateTestProbe(); + var actor = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + driverFactory: factory, + localRoles: new HashSet { "driver" })); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + return actor; + } + + /// + /// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's + /// TagConfig carries both a FullName and an alarm object so + /// DeploymentArtifact.ExtractTagAlarm projects a non-null EquipmentTagAlarmInfo — + /// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The + /// DriverInstances row carries a non-Windows-only DriverType ("GalaxyMxGateway") + an + /// Enabled flag so a REAL (non-stubbed) child is spawned. + /// + private static DeploymentId SeedDeploymentWithAlarmTag( + IDbContextFactory db, RevisionHash rev, + string Equip, string Driver, string FullName, string? Folder, string Name) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 + }, + DriverInstances = new[] + { + new + { + DriverInstanceRowId = Guid.NewGuid(), + DriverInstanceId = Driver, + Name = Driver, + DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed) + Enabled = true, + DriverConfig = "{}", + NamespaceId = "ns-eq", + }, + }, + Tags = new[] + { + new + { + TagId = "tag-0", + EquipmentId = Equip, + DriverInstanceId = Driver, + Name, + FolderPath = Folder, + DataType = "Boolean", + TagConfig = JsonSerializer.Serialize(new + { + FullName, + alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, + }), + }, + }, + }); + + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, + RevisionHash = rev.Value, + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + + /// Factory producing a single for the supported type, whose + /// recorded acknowledge list is exposed for assertions. + private sealed class RecordingAlarmDriverFactory : IDriverFactory + { + private readonly string _supportedType; + private readonly RecordingAlarmDriver _driver = new(); + public RecordingAlarmDriverFactory(string supportedType) { _supportedType = supportedType; } + + /// The acknowledge requests the spawned driver received (thread-safe — AcknowledgeAsync runs + /// off the actor thread). + public IReadOnlyList Acks => _driver.Acks; + + /// + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + { + if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; + _driver.Bind(driverInstanceId, driverType); + return _driver; + } + + /// + public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; + } + + /// An + that records every acknowledge. + private sealed class RecordingAlarmDriver : IDriver, IAlarmSource + { + private readonly ConcurrentQueue _acks = new(); + /// + public string DriverInstanceId { get; private set; } = string.Empty; + /// + public string DriverType { get; private set; } = string.Empty; + /// The acknowledge requests received so far. + public IReadOnlyList Acks => _acks.ToArray(); + /// Sets the identity once the factory is asked to create it. + public void Bind(string id, string type) { DriverInstanceId = id; DriverType = type; } + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null); + /// + public long GetMemoryFootprint() => 0; + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) => + Task.FromResult(new StubAlarmHandle()); + + /// + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => + Task.CompletedTask; + + /// + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + foreach (var a in acknowledgements) _acks.Enqueue(a); + return Task.CompletedTask; + } + + /// + public event EventHandler? OnAlarmEvent + { + add { } + remove { } + } + + private sealed class StubAlarmHandle : IAlarmSubscriptionHandle + { + public string DiagnosticId => "stub-alarm-sub"; + } + } +}