refactor(localdb): one replication predicate shared by table registration and the alarm drain gate (#603)

The drain gate counted SyncListenPort; a port with no ApiKey shares nothing (fail-closed
passive endpoint), and an ApiKey-only passive node shares its queue but was never gated.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:56:00 -04:00
parent 1b37b2f2b0
commit 4f6231fa40
6 changed files with 94 additions and 18 deletions
@@ -122,14 +122,18 @@ public sealed class AlarmHistorianRegistrationTests
/// </remarks>
[Theory]
// Neither key: an unpaired node. Its rows exist nowhere else.
[InlineData(null, null, true)]
[InlineData(null, null, null, true)]
// The dialing half of a pair — gated, because its partner holds the same rows.
[InlineData("http://peer:9001", null, false)]
[InlineData("http://peer:9001", null, null, false)]
// The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would
// wrongly class it as unpaired and leave it permanently ungated.
[InlineData(null, "9001", false)]
// wrongly class it as unpaired and leave it permanently ungated. It is identified by the shared
// ApiKey (the same predicate LocalDbSetup registers tables on), not by its listen port.
[InlineData(null, "9001", "k", false)]
// A listen port with NO key: the fail-closed passive endpoint rejects every inbound stream, so the
// queue is shared with nobody and the node must keep draining (#603: this used to be gated).
[InlineData(null, "9001", null, true)]
public async Task Only_an_unreplicated_node_ignores_the_role_view(
string? peerAddress, string? syncListenPort, bool expectDrain)
string? peerAddress, string? syncListenPort, string? apiKey, bool expectDrain)
{
var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
@@ -143,6 +147,7 @@ public sealed class AlarmHistorianRegistrationTests
["LocalDb:Path"] = dbPath,
["LocalDb:Replication:PeerAddress"] = peerAddress,
["LocalDb:SyncListenPort"] = syncListenPort,
["LocalDb:Replication:ApiKey"] = apiKey,
});
var services = BaseServices();
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
/// <summary>#603: the single "is this node's LocalDb replicated" predicate shared by table registration and the alarm drain gate.</summary>
public sealed class LocalDbReplicationPolicyTests
{
private static IConfiguration Config(params (string Key, string? Value)[] pairs) =>
new ConfigurationBuilder()
.AddInMemoryCollection(pairs.Select(p => new KeyValuePair<string, string?>(p.Key, p.Value)))
.Build();
/// <summary>PeerAddress OR ApiKey counts; the listen port alone and blank values do not.</summary>
[Theory]
[InlineData("LocalDb:Replication:PeerAddress", "http://peer:9001", true)]
[InlineData("LocalDb:Replication:ApiKey", "k", true)]
[InlineData("LocalDb:SyncListenPort", "9001", false)]
[InlineData("LocalDb:Replication:ApiKey", " ", false)]
public void IsConfigured_follows_the_PeerAddress_or_ApiKey_rule(string key, string value, bool expected)
{
LocalDbReplicationPolicy.IsConfigured(Config((key, value))).ShouldBe(expected);
}
/// <summary>No replication keys at all is an unpaired node.</summary>
[Fact]
public void IsConfigured_is_false_for_an_empty_configuration()
{
LocalDbReplicationPolicy.IsConfigured(Config()).ShouldBeFalse();
}
}