feat(localdb): alarm S&F on LocalDb with primary-gated drain (cutover)
Moves the alarm store-and-forward buffer out of its own alarm-historian.db and into the node's consolidated LocalDb, where it replicates to the redundant pair peer. A node that dies holding undelivered alarm history no longer takes it to the grave. Tasks 2 and 3 land together, as the plan anticipated. They are not separable: the gate is a constructor argument of the rewritten sink, and a commit that replicated the queue without gating the drain would be a commit in which both nodes of a pair deliver every alarm event, continuously. That drain gate is the load-bearing part of the change, and the recon explains why it is new work rather than a refinement. Exactly-once delivery across a pair is enforced today on the ENQUEUE side, by HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is empty and its ungated drain has nothing to send. Replicating the table destroys that invariant. The gate is a Func<bool> the caller supplies, because the drain runs on a timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate verdict to -- the same verdict the inbound-write and native-ack gates use, so there is no second notion of am-I-the-Primary to drift. Two failure modes are deliberately closed: - The view is seeded OPEN, matching the policy's own answer for an unknown role with no driver peer. A deployment that runs no redundancy never publishes to it, and defaulting closed would silently stop its alarm history forever. - A gate that throws is read as not-now, never as permission, and a closed gate reports the new HistorianDrainState.NotPrimary rather than Idle. A Secondary's rising queue is supposed to look different from a stalled drain, and if BOTH nodes report NotPrimary the pair is misconfigured and says so instead of quietly filling toward the capacity ceiling. Row ids are a hash of the payload rather than fresh GUIDs. Both adapters accept the same fanned transition in the window before the first redundancy snapshot arrives, and under last-writer-wins an equal key converges those two accepts into one row instead of duplicating them. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+44
-22
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
|
||||
@@ -11,12 +13,12 @@ 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="SqliteStoreAndForwardSink"/> wins
|
||||
/// 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 Sqlite sink; never actually invoked in these tests.</summary>
|
||||
/// <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(
|
||||
@@ -65,34 +67,61 @@ public sealed class AlarmHistorianRegistrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_enabled_registers_sqlite_sink()
|
||||
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, "alarm-historian-test.db");
|
||||
var dbPath = Path.Combine(tempDir, "node-local.db");
|
||||
|
||||
try
|
||||
{
|
||||
var services = BaseServices();
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["AlarmHistorian:Enabled"] = "true",
|
||||
["AlarmHistorian:DatabasePath"] = dbPath,
|
||||
["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<SqliteStoreAndForwardSink>();
|
||||
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>
|
||||
/// 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()
|
||||
{
|
||||
@@ -112,17 +141,10 @@ public sealed class AlarmHistorianRegistrationTests
|
||||
opts.DeadLetterRetentionDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_relative_database_path_when_enabled()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db" };
|
||||
opts.Validate().ShouldContain(w => w.Contains("DatabasePath"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_is_silent_when_correctly_configured()
|
||||
{
|
||||
new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty();
|
||||
new AlarmHistorianOptions { Enabled = true }.Validate().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,39 +156,39 @@ public sealed class AlarmHistorianRegistrationTests
|
||||
[Fact]
|
||||
public void Validate_warns_on_non_positive_drain_interval()
|
||||
{
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 };
|
||||
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, DatabasePath = "/abs/h.db", Capacity = 0 };
|
||||
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, DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 };
|
||||
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, DatabasePath = "/abs/h.db", MaxAttempts = 0 };
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, MaxAttempts = 0 };
|
||||
opts.Validate().ShouldContain(w => w.Contains("MaxAttempts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_accumulates_multiple_warnings()
|
||||
{
|
||||
// relative path + non-positive drain interval ⇒ both warnings, not short-circuited on the first.
|
||||
var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db", DrainIntervalSeconds = 0 };
|
||||
// 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("DatabasePath"));
|
||||
warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
|
||||
warnings.ShouldContain(w => w.Contains("Capacity"));
|
||||
warnings.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user