f9f1b8fcee
Gate record: docs/plans/2026-07-20-localdb-phase2-live-gate.md. Checks 1, 2, 5, 6 pass. Checks 3 and 4 are NOT satisfied — the defect they were meant to confirm turned out to be the opposite of what the plan assumed. Three defects crash-looped every driver node before check 1 could even run: 1. An empty ServerHistorian:ApiKey kills the host. ServerHistorianOptions- Validator exists to turn exactly that class of failure into a named OptionsValidationException, but its documented fail tier explicitly excluded ApiKey on the reasoning that a keyless client "degrades — the gateway rejects calls". It does not: the client validates its own options at construction, so the process dies during Akka startup and never makes a call. 2. UseTls disagreeing with the endpoint scheme kills the host too, in both directions (both messages confirmed in the shipped client assembly). Moving an endpoint from https to http without clearing UseTls is an ordinary migration slip. 3. Plaintext h2c was UNREACHABLE. HistorianGatewayClientAdapter forwarded the TLS-only options unconditionally, and AllowUntrustedServerCertificate defaults to false, so it always sent RequireCertificateValidation=true — which the client rejects outright when UseTls=false. Every http:// deployment crashed, though the scheme is documented as the supported way to select h2c, and the only workaround was to assert a certificate posture for a connection that has no certificate. The fourth was the blocker, and it is Phase 2's own: 4. The drain gate deferred to a Primary that cannot deliver. Redundancy roles are elected CLUSTER-WIDE; the alarm queue is PAIR-LOCAL. On the rig the elected driver Primary is central-1 — it carries the driver Akka role, replicates nobody's LocalDb and does not even run the alarm historian — so every driver node logged "Historian drain suspended", including the two site-b nodes that have no peer at all. Nothing drained anywhere, where before Phase 2 it drained fine. The cost is not a duplicate; it is the buffer growing to the capacity wall and evicting the audit trail it exists to protect. Fixed in three layers: a separate ShouldDrainAlarmHistory policy (unknown role drains; the two gates now deliberately disagree, and a test pins that); peer- host matching in DriverHostActor so a node stands down only for a Primary holding its rows; and AddAlarmHistorian short-circuiting the gate when replication is unconfigured — testing BOTH Replication:PeerAddress and SyncListenPort, since only the dialing half sets the former while both halves share the queue. Every one of these follows from the asymmetry: a false allow costs a duplicate row, which at-least-once delivery already accepts and payload-hash ids collapse; a false deny loses data silently. A third vacuous test, caught by the same delete-the-guard discipline: the role-view tests stayed green with the guard removed, because AwaitAssert polls until an assertion passes and the assertion was "reads open" — which is the SEEDED value, satisfied at the first poll before the actor processed anything. They now assert the sequence of published values through a recording view; the control then goes red for exactly the cases that matter. Migration evidence: 11 legacy rows across two deliberately overlapping files converged to exactly 9 identical rows on both nodes, proving D-6's payload-hash identity on real nodes rather than in a fixture. Open design fork, recorded in the gate doc rather than decided here: a pair cannot currently identify its own Primary, so both halves drain. Safe in every topology — nothing loses data — but the gate's de-duplication benefit is unrealised until roles are scoped per pair. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
299 lines
12 KiB
C#
299 lines
12 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
|
|
|
/// <summary>
|
|
/// Verifies the config-gated <c>AddAlarmHistorian</c> registration: when the
|
|
/// <c>AlarmHistorian</c> section is absent or disabled the <see cref="NullAlarmHistorianSink"/>
|
|
/// default survives; when it is enabled a real <see cref="LocalDbStoreAndForwardSink"/> wins
|
|
/// (last-registration-wins over the <c>TryAddSingleton</c> Null default).
|
|
/// </summary>
|
|
public sealed class AlarmHistorianRegistrationTests
|
|
{
|
|
/// <summary>A no-op writer the factory hands the sink; never actually invoked in these tests.</summary>
|
|
private sealed class FakeWriter : IAlarmHistorianWriter
|
|
{
|
|
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
|
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
|
=> Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(
|
|
batch.Select(_ => HistorianWriteOutcome.Ack).ToList());
|
|
}
|
|
|
|
/// <summary>Seed the Null default exactly the way <c>AddOtOpcUaRuntime</c> does, then add logging.</summary>
|
|
private static ServiceCollection BaseServices()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
|
return services;
|
|
}
|
|
|
|
private static IConfiguration ConfigFrom(Dictionary<string, string?> values)
|
|
=> new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
|
|
[Fact]
|
|
public void Section_absent_keeps_null_sink()
|
|
{
|
|
var services = BaseServices();
|
|
var config = ConfigFrom(new Dictionary<string, string?>());
|
|
|
|
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
provider.GetRequiredService<IAlarmHistorianSink>().ShouldBeOfType<NullAlarmHistorianSink>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Section_disabled_keeps_null_sink()
|
|
{
|
|
var services = BaseServices();
|
|
var config = ConfigFrom(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = "false",
|
|
});
|
|
|
|
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
provider.GetRequiredService<IAlarmHistorianSink>().ShouldBeOfType<NullAlarmHistorianSink>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Section_enabled_registers_the_localdb_sink()
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(tempDir);
|
|
var dbPath = Path.Combine(tempDir, "node-local.db");
|
|
|
|
try
|
|
{
|
|
var config = ConfigFrom(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = "true",
|
|
["LocalDb:Path"] = dbPath,
|
|
});
|
|
|
|
var services = BaseServices();
|
|
services.AddZbLocalDb(config, db =>
|
|
{
|
|
using var connection = db.CreateConnection();
|
|
AlarmSfSchema.Apply(connection);
|
|
db.RegisterReplicated(AlarmSfSchema.EventsTable);
|
|
});
|
|
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
|
|
|
|
using (var provider = services.BuildServiceProvider())
|
|
{
|
|
provider.GetRequiredService<IAlarmHistorianSink>().ShouldBeOfType<LocalDbStoreAndForwardSink>();
|
|
} // dispose stops the drain loop + releases the SQLite file handle
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node whose LocalDb is not replicated drains its own queue regardless of redundancy role.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Standing down is only safe because a peer holds the same rows and will send them
|
|
/// instead. Without <c>LocalDb:Replication:PeerAddress</c> there is no such peer: the rows
|
|
/// are here and nowhere else, so any deferral means nobody ever delivers them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Found by the Phase 2 live gate. The redundancy role is a CLUSTER-WIDE election while the
|
|
/// queue is PAIR-LOCAL, and on the docker-dev rig the elected driver Primary turned out to
|
|
/// be a central node — one that carries the driver Akka role, replicates nobody's LocalDb
|
|
/// and does not even run the alarm historian. Every site node suspended its drain in favour
|
|
/// of a node that could not possibly deliver its events.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Theory]
|
|
// Neither key: an unpaired node. Its rows exist nowhere else.
|
|
[InlineData(null, null, true)]
|
|
// The dialing half of a pair — gated, because its partner holds the same rows.
|
|
[InlineData("http://peer:9001", 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)]
|
|
public async Task Only_an_unreplicated_node_ignores_the_role_view(
|
|
string? peerAddress, string? syncListenPort, bool expectDrain)
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(tempDir);
|
|
var dbPath = Path.Combine(tempDir, "node-local.db");
|
|
|
|
try
|
|
{
|
|
var config = ConfigFrom(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = "true",
|
|
["LocalDb:Path"] = dbPath,
|
|
["LocalDb:Replication:PeerAddress"] = peerAddress,
|
|
["LocalDb:SyncListenPort"] = syncListenPort,
|
|
});
|
|
|
|
var services = BaseServices();
|
|
services.AddZbLocalDb(config, db =>
|
|
{
|
|
using var connection = db.CreateConnection();
|
|
AlarmSfSchema.Apply(connection);
|
|
db.RegisterReplicated(AlarmSfSchema.EventsTable);
|
|
});
|
|
|
|
// A role view pinned SHUT: this node is a Secondary and a Primary exists elsewhere.
|
|
services.AddSingleton<IRedundancyRoleView>(new StandDownRoleView());
|
|
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
|
|
|
|
using (var provider = services.BuildServiceProvider())
|
|
{
|
|
var sink = provider.GetRequiredService<IAlarmHistorianSink>();
|
|
await sink.EnqueueAsync(SampleEvent, CancellationToken.None);
|
|
|
|
await ((LocalDbStoreAndForwardSink)sink).DrainOnceAsync(CancellationToken.None);
|
|
|
|
var status = sink.GetStatus();
|
|
if (expectDrain)
|
|
{
|
|
// Delivered and removed — not parked behind a gate that nobody else will open.
|
|
status.DrainState.ShouldNotBe(HistorianDrainState.NotPrimary);
|
|
status.QueueDepth.ShouldBe(0);
|
|
}
|
|
else
|
|
{
|
|
// Held for the peer that shares this queue.
|
|
status.DrainState.ShouldBe(HistorianDrainState.NotPrimary);
|
|
status.QueueDepth.ShouldBe(1);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>A view that always says "some other node is the Primary".</summary>
|
|
private sealed class StandDownRoleView : IRedundancyRoleView
|
|
{
|
|
public bool ShouldDrainAlarmHistory => false;
|
|
|
|
public void Publish(bool shouldDrainAlarmHistory) { }
|
|
}
|
|
|
|
private static AlarmHistorianEvent SampleEvent => new(
|
|
AlarmId: "eq/alarm-1",
|
|
EquipmentPath: "line-1/eq-1",
|
|
AlarmName: "temp-high",
|
|
AlarmTypeName: "LimitAlarm",
|
|
Severity: ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High,
|
|
EventKind: "Activated",
|
|
Message: "temperature high",
|
|
User: "system",
|
|
Comment: null,
|
|
TimestampUtc: new DateTime(2026, 7, 21, 0, 0, 1, DateTimeKind.Utc));
|
|
|
|
/// <summary>
|
|
/// An enabled historian with no LocalDb registered must fail at resolution, loudly.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The queue has nowhere to live without it. Falling back to the Null sink here would look
|
|
/// like a working historian while silently discarding every alarm event — the failure mode
|
|
/// this whole registration is meant to make impossible.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Section_enabled_without_localdb_throws_rather_than_silently_discarding()
|
|
{
|
|
var services = BaseServices();
|
|
var config = ConfigFrom(new Dictionary<string, string?> { ["AlarmHistorian:Enabled"] = "true" });
|
|
|
|
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
Should.Throw<InvalidOperationException>(() => provider.GetRequiredService<IAlarmHistorianSink>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Section_binds_drain_capacity_and_retention_knobs()
|
|
{
|
|
var config = ConfigFrom(new Dictionary<string, string?>
|
|
{
|
|
["AlarmHistorian:Enabled"] = "true",
|
|
["AlarmHistorian:DrainIntervalSeconds"] = "11",
|
|
["AlarmHistorian:Capacity"] = "500",
|
|
["AlarmHistorian:DeadLetterRetentionDays"] = "7",
|
|
});
|
|
|
|
var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get<AlarmHistorianOptions>();
|
|
|
|
opts.ShouldNotBeNull();
|
|
opts.DrainIntervalSeconds.ShouldBe(11);
|
|
opts.Capacity.ShouldBe(500);
|
|
opts.DeadLetterRetentionDays.ShouldBe(7);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_is_silent_when_correctly_configured()
|
|
{
|
|
new AlarmHistorianOptions { Enabled = true }.Validate().ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_is_silent_when_disabled()
|
|
{
|
|
new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_warns_on_non_positive_drain_interval()
|
|
{
|
|
var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0 };
|
|
opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_warns_on_non_positive_capacity()
|
|
{
|
|
var opts = new AlarmHistorianOptions { Enabled = true, Capacity = 0 };
|
|
opts.Validate().ShouldContain(w => w.Contains("Capacity"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_warns_on_non_positive_retention()
|
|
{
|
|
var opts = new AlarmHistorianOptions { Enabled = true, DeadLetterRetentionDays = 0 };
|
|
opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_warns_on_non_positive_max_attempts()
|
|
{
|
|
var opts = new AlarmHistorianOptions { Enabled = true, MaxAttempts = 0 };
|
|
opts.Validate().ShouldContain(w => w.Contains("MaxAttempts"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_accumulates_multiple_warnings()
|
|
{
|
|
// Two bad knobs ⇒ both warnings, not short-circuited on the first.
|
|
var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0, Capacity = 0 };
|
|
var warnings = opts.Validate();
|
|
warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
|
|
warnings.ShouldContain(w => w.Contains("Capacity"));
|
|
warnings.Count.ShouldBeGreaterThanOrEqualTo(2);
|
|
}
|
|
}
|