diff --git a/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md b/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md index b008f6bb..9d0de32d 100644 --- a/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md +++ b/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md @@ -521,3 +521,19 @@ drifted (it claimed the field was set at the end of `PushDesiredSubscriptions`; `ApplyDesiredSubscriptions`) — deleted with the field rather than corrected. The stale "dropped-driver fallback" comment on the old `SendAuthoredOnly` local function was rewritten as the `SendDesiredSubscriptions` remarks naming the three real consumers. + +### T4 — test harness (seeder `enabled` flag + recording driver factory) + +Test-only, additive, source-compatible: every pre-existing `SeedV3Deployment` / `SpawnHostAndApply` +call site compiles and behaves identically (the new parameters are trailing and default to today's +values). Gate: `--filter "…~DriverHostActor|…~DriverInstanceActor"` → **179/179 passed** (the plan +named `--filter "…~V3"`, which matches nothing — the seeder's four consumers are all `DriverHostActor*` +classes; the wider filter is the same set plus more). + +**Deviation (recorded).** The plan lists one new seeder parameter (`enabled`); a second was needed and +added: `IReadOnlyDictionary? tagConfigByTag = null`. The seeder hard-codes +`TagConfig = "{}"` on every tag, and a raw tag's alarm is derived *only* from `TagConfig.alarm` via +`TagConfigIntent.Parse` (`AddressSpaceComposer.cs:769-772`) — so without it there is no way to seed the +alarm-bearing raw tag that `Operator_restart_re_subscribes_alarm_refs_too` (a test the plan itself +specifies) requires. It is trailing and optional, so it is the same source-compatible shape as +`enabled`. diff --git a/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md.tasks.json b/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md.tasks.json index 2a3500fc..42819372 100644 --- a/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md.tasks.json +++ b/archreview/plans/R3-03-restart-resubscribe-watchdog-plan.md.tasks.json @@ -25,7 +25,7 @@ { "id": "T4", "subject": "Test harness: V3ArtifactSeeder enabled flag + RecordingSubscribableDriver(Factory) with a crash seam", - "status": "pending", + "status": "done", "blockedBy": [] }, { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs index 25d459f4..30a51113 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs @@ -188,3 +188,196 @@ internal sealed class StubHandle : ISubscriptionHandle /// Gets the diagnostic ID of the subscription. public string DiagnosticId => "stub-sub"; } + +/// +/// A per-instance recording driver for the restart / re-subscribe suite (03/S16, U6): unlike +/// it carries the spec's +/// ( hard-codes a constant on a non-virtual member), and it counts and records the +/// alarm subscription alongside the value one — the two travel in a single +/// SetDesiredSubscriptions but through two different child code paths, so both need observing. +/// +/// +/// +/// The falsification bar these counters exist for: a restart test must assert a FUNCTION the new +/// incarnation performs — it subscribed, it re-initialised — never that the incarnation exists. +/// Existence is what +/// DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision +/// asserts, and that test passes against the very defect 03/S16 names. +/// +/// +/// is the supervision crash seam — see +/// DriverHostActorRestartTests's file remarks for why the obvious alternatives do not work. +/// +/// +internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAlarmSource +{ + private readonly Lock _gate = new(); + private readonly StubHandle _handle = new(); + private readonly RecordingAlarmHandle _alarmHandle = new(); + private int _initializeCount; + private int _shutdownCount; + private int _subscribeCount; + private int _subscribeAlarmsCount; + private IReadOnlyList? _lastSubscribedRefs; + private IReadOnlyList? _lastSubscribedAlarmRefs; + + /// Initializes a new recording driver stamped with the spec's identity. + /// The driver instance id from the spec that created it. + /// The driver type from the spec that created it. + public RecordingSubscribableDriver(string driverInstanceId, string driverType) + { + DriverInstanceId = driverInstanceId; + DriverType = driverType; + } + + /// + public string DriverInstanceId { get; } + + /// + public string DriverType { get; } + + /// Number of calls. A supervision restart re-uses the SAME driver + /// object (the Props closure captured it), so this going 1 to 2 is how the reprovision is observed. + public int InitializeCount => Volatile.Read(ref _initializeCount); + + /// Number of calls — a restart's PostStop raises it. + public int ShutdownCount => Volatile.Read(ref _shutdownCount); + + /// Number of calls. + public int SubscribeCount => Volatile.Read(ref _subscribeCount); + + /// Number of calls. + public int SubscribeAlarmsCount => Volatile.Read(ref _subscribeAlarmsCount); + + /// The refs passed to the most recent , or null if never called. + public IReadOnlyList? LastSubscribedRefs + { + get { lock (_gate) return _lastSubscribedRefs; } + } + + /// The refs passed to the most recent , or null if never called. + public IReadOnlyList? LastSubscribedAlarmRefs + { + get { lock (_gate) return _lastSubscribedAlarmRefs; } + } + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _initializeCount); + return Task.CompletedTask; + } + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _shutdownCount); + return Task.CompletedTask; + } + + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + + /// + public event EventHandler? OnDataChange; + + /// + public Task SubscribeAsync( + IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _subscribeCount); + lock (_gate) _lastSubscribedRefs = fullReferences.ToArray(); + return Task.FromResult(_handle); + } + + /// + public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public event EventHandler? OnAlarmEvent; + + /// + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _subscribeAlarmsCount); + lock (_gate) _lastSubscribedAlarmRefs = sourceNodeIds.ToArray(); + return Task.FromResult(_alarmHandle); + } + + /// + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) => Task.CompletedTask; + + /// Fires a well-formed data change (the ordinary production path). + /// The reference whose value changed. + /// The new value. + public void FireDataChange(string fullRef, object? value) => + OnDataChange?.Invoke(this, new DataChangeEventArgs( + _handle, fullRef, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow))); + + /// + /// Raises with a NULL Snapshot — the supervision crash seam. + /// DataChangeEventArgs is a positional record with no null validation, and the actor's + /// OnDataChangeForward dereferences msg.Snapshot unguarded ON THE ACTOR THREAD, so this + /// produces a genuine unhandled exception inside the child, which Akka's default decider turns into a + /// Restart. The fault itself is arbitrary; what is under test is the RECOVERY. + /// + /// The reference to name in the malformed event. + public void FireMalformedDataChange(string fullRef) => + OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, null!)); + + /// Raises ; present so the event is not merely written-to-never-read. + /// The condition id to raise. + public void FireAlarm(string conditionId) => + OnAlarmEvent?.Invoke(this, new AlarmEventArgs( + _alarmHandle, conditionId, conditionId, "AlarmCondition", "test", AlarmSeverity.High, DateTime.UtcNow)); + + private sealed class RecordingAlarmHandle : IAlarmSubscriptionHandle + { + /// + public string DiagnosticId => "recording-alarm-sub"; + } +} + +/// +/// An that hands out s and KEEPS +/// every one it created, in creation order. The list — not a single "the driver" reference — is the point: +/// an operator Restart tears the child down and spawns a new one around a NEW driver object, so the +/// assertion subject is Created[1], while a SUPERVISION restart re-uses Created[0] (the Props +/// closure captured it) and part of the assertion is that Created.Count stayed 1. +/// +internal sealed class RecordingSubscribableDriverFactory : IDriverFactory +{ + private readonly string _supportedType; + private readonly Lock _gate = new(); + private readonly List _created = new(); + + /// Initializes a factory that materialises exactly one driver type. + /// The driver type this factory answers for. + public RecordingSubscribableDriverFactory(string supportedType) => _supportedType = supportedType; + + /// Every driver this factory created, in creation order. + public IReadOnlyList Created + { + get { lock (_gate) return _created.ToArray(); } + } + + /// + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + { + if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; + var driver = new RecordingSubscribableDriver(driverInstanceId, driverType); + lock (_gate) _created.Add(driver); + return driver; + } + + /// + public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs index 4ffc9a1d..4831fdd2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs @@ -9,6 +9,7 @@ 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.OpcUa; @@ -36,16 +37,28 @@ public static class V3ArtifactSeeder /// The revision hash the deployment is sealed under. /// The raw tags to materialise, one per (driver, device, tag). /// UNS references projecting a raw tag (by BackingTag) into an equipment. + /// Whether the seeded DriverInstances are Enabled — i.e. whether applying this + /// artifact actually SPAWNS driver children. Defaults to false, the shape every pre-existing caller + /// relies on: the value-routing suites inject values straight into the host and want no children at all. + /// The restart/re-subscribe suite (03/S16) passes true, because its whole subject is what a spawned + /// child is handed. + /// Optional per-tag TagConfig JSON, keyed by tag name; tags not named + /// here keep the default "{}". The only way to seed an alarm-bearing raw tag (the alarm intent + /// is parsed out of TagConfig.alarm by TagConfigIntent), which the alarm-ref half of the + /// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried + /// in the alarm one instead, so the two travel through different child code paths. /// The seeded deployment's id. public static DeploymentId SeedV3Deployment( IDbContextFactory db, RevisionHash rev, (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags, - (string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs) + (string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs, + bool enabled = false, + IReadOnlyDictionary? tagConfigByTag = null) { var drivers = rawTags .Select(t => (t.Driver, t.DriverName)) .Distinct() - .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }) + .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }) .ToArray(); var devices = rawTags .Select(t => (t.Driver, t.Device)) @@ -60,7 +73,7 @@ public static class V3ArtifactSeeder Name = t.Tag, DataType = t.DataType, AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1 - TagConfig = "{}", + TagConfig = tagConfigByTag is not null && tagConfigByTag.TryGetValue(t.Tag, out var tc) ? tc : "{}", }).ToArray(); // UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip). @@ -114,13 +127,17 @@ public static class V3ArtifactSeeder /// The host's local node id. /// The deployment to dispatch. /// The revision hash the deployment was sealed under. + /// Optional driver factory forwarded to DriverHostActor.Props. Null (the + /// default, preserving every existing call site) leaves the host on its own default, which materialises no + /// real drivers. Supply one — with enabled: true on the seed — to actually spawn driver children. /// The host actor plus the publish and mux probes wired into it. public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply( TestKitBase kit, IDbContextFactory db, - NodeId localNode, DeploymentId deploymentId, RevisionHash rev) + NodeId localNode, DeploymentId deploymentId, RevisionHash rev, + IDriverFactory? driverFactory = null) { var publish = kit.CreateTestProbe(); - var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref); + var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory); Dispatch(actor, coordinator, deploymentId, rev); publish.ExpectMsg(RuntimeActorTestBase.PresenceBudget); @@ -140,14 +157,14 @@ public static class V3ArtifactSeeder TestKitBase kit, IDbContextFactory db, NodeId localNode, DeploymentId deploymentId, RevisionHash rev) { - var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null); + var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null, driverFactory: null); Dispatch(actor, coordinator, deploymentId, rev); return (actor, mux); } private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn( TestKitBase kit, IDbContextFactory db, NodeId localNode, - IActorRef? opcUaPublishActor) + IActorRef? opcUaPublishActor, IDriverFactory? driverFactory) { var coordinator = kit.CreateTestProbe(); var mux = kit.CreateTestProbe(); @@ -155,6 +172,7 @@ public static class V3ArtifactSeeder var actor = kit.Sys.ActorOf(DriverHostActor.Props( db, localNode, coordinator.Ref, + driverFactory: driverFactory, localRoles: new HashSet { "driver" }, dependencyMux: mux.Ref, opcUaPublishActor: opcUaPublishActor,