e3dcb180c3
Review R1: - M1 (#609): DriverHostActor.StopChild releases the instance's resilience state before evicting the hub. - m2 (#597): DriverHostActor publishes ShouldServiceAsPrimary() to IDriverPrimaryRoleSource at start and on cluster membership changes (deduped), so the native-ack gate denies in a pair's boot window. - m4 (#601): ApplyCachedArtifact returns false on empty/malformed bytes; the cached revision is not adopted, Direct mode goes Stale (no RUNNING FROM CACHE), FetchAndCache lands Steady-with-no-revision and a re-dispatch of the same revision really applies. - m3 (#603): LocalDbReplicationPolicy splits into CapturesChanges (PeerAddress||ApiKey, registration) and SharesRowsWithPeer (ApiKey && (PeerAddress||SyncListenPort>0), alarm drain gate). - m5 (#618): Program.cs catch-all filters HostAbortedException (design-time tooling). - m6 (Cluster-002): ClusterRoleInfo seeds only Up/WeaklyUp members. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
309 lines
13 KiB
C#
309 lines
13 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 an authenticated sync path (<c>ApiKey</c> plus a dial or a listener —
|
|
/// <c>LocalDbReplicationPolicy.SharesRowsWithPeer</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, null, true)]
|
|
// The dialing half of a pair — gated, because its partner holds the same rows.
|
|
[InlineData("http://peer:9001", null, "k", 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. Key + listen port = a peer.
|
|
[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)]
|
|
// A dial with NO key: the peer's fail-closed endpoint rejects it — shares nothing, keeps draining.
|
|
[InlineData("http://peer:9001", null, null, true)]
|
|
// A key with NEITHER path (review R1 m3: e.g. a fleet-wide secret on an unpaired node) — no listener,
|
|
// no dial, so no peer holds these rows. Registered for capture, but must keep draining.
|
|
[InlineData(null, null, "k", true)]
|
|
public async Task Only_an_unreplicated_node_ignores_the_role_view(
|
|
string? peerAddress, string? syncListenPort, string? apiKey, 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,
|
|
["LocalDb:Replication:ApiKey"] = apiKey,
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|