using System.Security.Cryptography; using System.Text; using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; /// /// DDL for the alarm store-and-forward buffer: one row per alarm event awaiting delivery to /// the historian gateway. /// /// /// /// Replaces the standalone alarm-historian.db the sink used to own outright. Living /// in the consolidated LocalDb file is what lets the buffer replicate to the redundant pair /// peer, so a node that dies with undelivered alarm history no longer takes it to the grave. /// /// /// Deliberately depends on nothing but so it can be applied /// to any connection — the host's LocalDbSetup.OnReady in production, and a bare /// connection in a test — without dragging the DI graph along. Mirrors /// DeploymentCacheSchema. /// /// /// Why is TEXT and app-minted. The legacy queue keyed on /// RowId INTEGER PRIMARY KEY AUTOINCREMENT. Convergence is last-writer-wins over the /// primary key, so two nodes independently allocating rowid 7 for different alarms would /// silently overwrite one another. The sink mints the id from a hash of the payload, which /// additionally makes the same event converge to one row when both nodes of a pair enqueue /// it — as they legitimately do in the window before the first redundancy snapshot arrives. /// /// public static class AlarmSfSchema { /// Table holding queued alarm events awaiting delivery. public const string EventsTable = "alarm_sf_events"; /// The single primary-key column. public const string IdColumn = "id"; /// /// Derives a row's primary key from its serialized payload. /// /// /// /// Deterministic rather than a fresh GUID, so that the same event arriving twice /// converges to one row under last-writer-wins instead of duplicating. That happens for /// real in two places: HistorianAdapterActor default-writes while its redundancy /// role is unknown, so both nodes of a pair accept the same fanned transition in the /// window before the first snapshot; and both nodes independently migrate their own /// legacy queue file, which for a warm pair holds overlapping history. /// /// /// Two genuinely distinct events cannot collide. AlarmHistorianEvent carries a /// full-precision timestamp alongside the alarm id, transition kind, message and user, /// so an equal hash means an equal event. /// /// /// The serialized AlarmHistorianEvent. /// The row's primary key. public static string DeriveId(string payloadJson) { ArgumentNullException.ThrowIfNull(payloadJson); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))); } /// /// Creates the buffer table if it does not already exist. Idempotent. /// /// /// An already-open connection. ILocalDb.CreateConnection() hands out open, /// pragma-configured connections — do not call Open() on one. /// public static void Apply(SqliteConnection connection) { ArgumentNullException.ThrowIfNull(connection); using var cmd = connection.CreateCommand(); // Columns map 1:1 onto the legacy Queue table so the one-time migrator is a straight copy // and the drain keeps its existing semantics: dead_lettered is the same 0/1 flag, and an // acknowledged row is DELETEd rather than marked. Deleting keeps the table bounded without // a second sweeper, and the replication engine carries the delete as a tombstone, so the // peer drops its copy too. // // The drain index orders by enqueued_at_utc because a hashed TEXT id carries no insertion // order the way the legacy AUTOINCREMENT RowId did. Timestamps are round-trip ("O") format, // so lexicographic ordering is chronological; id breaks ties so the order is total and the // index covers the drain's read. cmd.CommandText = """ CREATE TABLE IF NOT EXISTS alarm_sf_events ( id TEXT NOT NULL PRIMARY KEY, alarm_id TEXT NOT NULL, enqueued_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL, attempt_count INTEGER NOT NULL DEFAULT 0, last_attempt_utc TEXT NULL, last_error TEXT NULL, dead_lettered INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS ix_alarm_sf_events_drain ON alarm_sf_events (dead_lettered, enqueued_at_utc, id); """; cmd.ExecuteNonQuery(); } }