Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
T
Joseph Doherty 4624141a5d test+docs: retire the stale EquipmentTagsDarkBatch4 skips, guard the deploy-time
tag gate, and fix three silently-broken OpcUaClient integration tests

Continues working through deferment.md's remaining items.

SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
  - REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
    the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
    E2E. The behaviour never went dark, only the v2 seeding did.
  - FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
    already compared RawTagPlan record-equal and RawTagPlan carries array /
    historize / alarm intent, so widening its corpus by two tags covers what
    three empty placeholder suites had been promising. Those suites are deleted.
  - DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
    and both dot-joint {{equip}}.X token suites (that resolver was deleted in
    Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.

G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.

OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.

DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-28 14:32:13 -04:00

444 lines
20 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using System.Text.Json;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
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.Observability;
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;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// archreview 03/S4 — the boot-window primary-gate guard. On a MULTI-driver cluster (member count &gt; 1)
/// while the role is UNKNOWN, the Primary data-plane gates (inbound write, native ack, native alerts emit)
/// default-DENY; on a SINGLE-driver cluster (count &lt;= 1) they default-ALLOW (boot-window / single-node
/// posture). Driver member count is injected via the <c>driverMemberCountProvider</c> Props seam.
/// </summary>
public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-pgate-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 7, 13, 10, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>v3 RawPath of the seeded write tag: RawFolder "Plant" / driver Name "Modbus" / Device "dev1" /
/// Tag "speed". Its UNS projection (Area "filling" / Line "line1" / Equipment "station1") is
/// <see cref="WriteUnsNodeId"/>.</summary>
private const string WriteRawPath = "Plant/Modbus/dev1/speed";
private const string WriteUnsNodeId = "filling/line1/station1/speed";
/// <summary>v3 RawPath of the seeded alarm tag. In v3 a native alarm materializes ONCE at the raw tag with
/// <c>ConditionId == RawPath</c>, so this is both the wire-ref the driver raises and the condition
/// NodeId the host routes to.</summary>
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
// ---------------- write gate ----------------
/// <summary>Role unknown + a real driver peer (count 2) ⇒ RouteNodeWrite is DENIED with the
/// boot-window reason and the driver sees no write.</summary>
[Fact]
public void Unknown_role_multi_driver_denies_write_with_role_unknown_reason()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var dep = SeedWriteTagDeployment(db);
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
result.Reason.ShouldBe("not primary (role unknown)");
recorder.Writes.ShouldBeEmpty();
}
/// <summary>Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window
/// posture preserved).</summary>
[Fact]
public void Unknown_role_single_driver_services_write()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var dep = SeedWriteTagDeployment(db);
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 1);
// The gate must ALLOW (count 1, role unknown). The driver child's async connect can lag the ApplyAck,
// so a first write may transiently report "driver not connected" — retry until connected (a real
// client retries too). The point under test is that the gate never denies (never "not primary").
AwaitAssert(() =>
{
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)");
res.Success.ShouldBeTrue(res.Reason);
}, duration: TimeSpan.FromSeconds(10));
recorder.Writes.ShouldNotBeEmpty();
}
/// <summary>A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state
/// reason (distinct from the boot-window reason).</summary>
[Fact]
public void Known_role_wins_over_member_count()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var dep = SeedWriteTagDeployment(db);
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
// Primary snapshot ⇒ serviced even with a driver peer. Retry through the transient driver-connect
// window; the invariant under test is that the gate never denies a Primary.
TellRole(actor, RedundancyRole.Primary);
AwaitAssert(() =>
{
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason);
}, duration: TimeSpan.FromSeconds(10));
// Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary");
}
/// <summary>The denial meter carries the site + reason tags.</summary>
[Fact]
public void Denied_write_increments_primary_gate_denied_meter_with_tags()
{
using var recorder = new MeterRecorder("otopcua.redundancy.primary_gate_denied");
var db = NewInMemoryDbFactory();
var factory = new RecordingDriverFactory("Modbus");
var dep = SeedWriteTagDeployment(db);
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
AwaitAssert(() =>
{
recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
recorder.WithTag("site", "write").ShouldBeGreaterThanOrEqualTo(1);
recorder.WithTag("reason", "role-unknown").ShouldBeGreaterThanOrEqualTo(1);
}, duration: Timeout);
}
// ---------------- ack gate ----------------
/// <summary>Role unknown + count 2 ⇒ a native-alarm ack is dropped at Warning and the denial meter
/// increments with site=ack.</summary>
[Fact]
public void Unknown_role_multi_driver_drops_native_ack_with_warning_and_meter()
{
using var meter = new MeterRecorder("otopcua.redundancy.primary_gate_denied");
var db = NewInMemoryDbFactory();
var dep = SeedAlarmTagDeployment(db);
var (actor, _) = SpawnAlarmHost(db, dep, driverMemberCount: 2);
EventFilter.Warning(contains: "role unknown").ExpectOne(() =>
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, Comment: null, OperatorUser: "op")));
AwaitAssert(() =>
{
meter.WithTag("site", "ack").ShouldBeGreaterThanOrEqualTo(1);
}, duration: Timeout);
}
// ---------------- alarm-emit gate ----------------
/// <summary>Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update
/// but publishes NO cluster-wide alerts transition.</summary>
[Fact]
public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition()
{
var db = NewInMemoryDbFactory();
var dep = SeedAlarmTagDeployment(db);
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnAlarmHost(db, dep, driverMemberCount: 2);
actor.Tell(RaiseAlarm());
// The ungated OPC UA condition write still arrives.
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).AlarmNodeId.ShouldBe(AlarmRawPath);
// The cluster-wide alerts publish is suppressed (a real Primary peer publishes the single copy).
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node
/// posture preserved).</summary>
[Fact]
public void Unknown_role_single_driver_publishes_alerts_emit()
{
var db = NewInMemoryDbFactory();
var dep = SeedAlarmTagDeployment(db);
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnAlarmHost(db, dep, driverMemberCount: 1);
actor.Tell(RaiseAlarm());
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
alerts.ExpectMsg<AlarmTransitionEvent>(Timeout).AlarmId.ShouldBe(AlarmRawPath);
}
// ---------------- helpers ----------------
private static DriverInstanceActor.AttributeAlarmPublished RaiseAlarm() =>
new("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp", // bare owning object — deliberately NOT the lookup key
ConditionId: AlarmRawPath, // v3: the condition is keyed by the raw tag's RawPath
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise));
private static void TellRole(IActorRef host, RedundancyRole role) =>
host.Tell(new RedundancyStateChanged(
new[]
{
new NodeRedundancyState(TestNode, role,
IsClusterLeader: role == RedundancyRole.Primary,
IsDriverPrimary: role == RedundancyRole.Primary,
AsOfUtc: DateTime.UtcNow),
},
CorrelationId.NewId()));
private IActorRef SpawnWriteHost(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId dep, IDriverFactory factory, int driverMemberCount)
{
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: factory,
localRoles: new HashSet<string> { "driver" },
driverMemberCountProvider: () => driverMemberCount));
actor.Tell(new DispatchDeployment(dep, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return actor;
}
private (IActorRef Actor, TestProbe Publish) SpawnAlarmHost(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId dep, int driverMemberCount)
{
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: mux.Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref,
driverMemberCountProvider: () => driverMemberCount));
actor.Tell(new DispatchDeployment(dep, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
return (actor, publish);
}
private void SubscribeToAlerts(TestProbe probe)
{
DistributedPubSub.Get(Sys).Mediator.Tell(
new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
probe.ExpectMsg<SubscribeAck>(Timeout);
}
/// <summary>
/// Seeds the v3 raw-tag chain a write must traverse: RawFolder "Plant" → DriverInstance "drv-1"
/// (Modbus, <b>Enabled</b> so a real child spawns and can service the write) → Device "dev1" → Tag
/// "speed" (<see cref="WriteRawPath"/>, ReadWrite), projected into equipment
/// <c>filling/line1/station1</c> by a <c>UnsTagReference</c> so <see cref="WriteUnsNodeId"/> resolves.
/// <para>The pre-v3 shape this replaced seeded <c>Namespaces</c> + equipment-bound <c>Tags</c>, which
/// v3 parses to an EMPTY tag set — the write had nothing to route to, which is why the
/// write-is-serviced cases were skipped rather than failing.</para>
/// </summary>
private static DeploymentId SeedWriteTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
});
return SealArtifact(db, artifact);
}
/// <summary>
/// Seeds the same v3 chain with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object, so the
/// composer makes it a Part 9 condition at <see cref="AlarmRawPath"/> rather than a value variable.
/// The driver is <b>disabled</b> — these cases exercise the ack/emit gates, not a live child.
/// </summary>
private static DeploymentId SeedAlarmTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "t-temp",
DeviceId = "dev-1",
TagGroupId = (string?)null,
Name = "temp_hi",
DataType = "Boolean",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(new
{
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
}),
},
},
});
return SealArtifact(db, artifact);
}
private static DeploymentId SealArtifact(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] artifact)
{
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = RevA.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
{
public string DiagnosticId => "stub-alarm-sub";
}
private sealed class RecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly RecordingDriver _driver = new();
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
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<string> SupportedTypes => new[] { _supportedType };
}
private sealed class RecordingDriver : IDriver, IWritable
{
private readonly ConcurrentQueue<WriteRequest> _writes = new();
public string DriverInstanceId { get; private set; } = string.Empty;
public string DriverType { get; private set; } = string.Empty;
public IReadOnlyList<WriteRequest> Writes => _writes.ToArray();
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<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
foreach (var w in writes) _writes.Enqueue(w);
return Task.FromResult<IReadOnlyList<WriteResult>>(writes.Select(_ => new WriteResult(0u)).ToArray());
}
}
private sealed class MeterRecorder : IDisposable
{
private readonly string _name;
private readonly MeterListener _listener;
private long _total;
private readonly List<KeyValuePair<string, object?>[]> _tagSets = new();
private readonly object _gate = new();
public MeterRecorder(string instrumentName)
{
_name = instrumentName;
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == _name)
listener.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
lock (_gate) { _total += value; _tagSets.Add(tags.ToArray()); }
});
_listener.Start();
}
public long Total { get { lock (_gate) return _total; } }
public int WithTag(string key, string value)
{
lock (_gate) return _tagSets.Count(set => set.Any(t => t.Key == key && Equals(t.Value, value)));
}
public void Dispose() => _listener.Dispose();
}
}