Merge commit '82380d2b' into remed/integration-w3

This commit is contained in:
Joseph Doherty
2026-10-22 17:21:38 -04:00
60 changed files with 5954 additions and 752 deletions
@@ -1,11 +1,13 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
/// trail and comment history.
/// DDL for the Part 9 scripted-alarm condition state: one fixed-size row per alarm identity
/// holding the operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the
/// ack/confirm audit fields, and a separate append-only table holding each alarm's comment trail.
/// </summary>
/// <remarks>
/// <para>
@@ -34,14 +36,58 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
/// chronological; nullable timestamps and audit fields are stored as NULL.
/// </para>
/// <para>
/// <b>The comment trail is NOT on the condition row.</b> It used to be serialized whole into
/// <c>comments_json</c>, so every transition rewrote — and replicated — the alarm's entire
/// history: O(n) per transition, O(n²) over the alarm's life (review R05:4). It now lives in
/// <see cref="CommentsTable"/>, one row per entry keyed by <c>(scripted_alarm_id, seq)</c>,
/// where <c>seq</c> is the entry's ordinal in the alarm's trail. A transition inserts only the
/// entries it appended, so the condition row stays a fixed size and a transition's write is
/// O(1) however long the history is. <c>comments_json</c> is kept, always written as
/// <c>"[]"</c>, so the condition table's shape — and with it the pair's replication digest
/// for that table — does not change and an old-shape row stays readable;
/// <see cref="MigrateInlineComments"/> copies any inline trail across (the alarm's next
/// save clears the column).
/// </para>
/// <para>
/// <b>Why the ordinal, and not a content hash, is the key.</b> Both halves of a redundant pair
/// run the engine and both apply every operator command, each stamping the entry with its own
/// clock. A content-derived id would keep TWO rows for every logical entry once the pair
/// converged. Keyed by ordinal, the two nodes' copies of entry <i>n</i> share one primary key
/// and LWW collapses them to one row — the convergence the whole-row LWW gave before, now per
/// entry. Entries go in with <c>INSERT OR IGNORE</c> (<see cref="InsertCommentSql"/>), so an
/// entry already held — written locally, or arrived from the peer — is never rewritten. A
/// node that MISSED a peer command keeps its own colliding entry by appending it after the
/// stored trail, with a Warning (see the store's divergence handling).
/// </para>
/// </remarks>
public static class AlarmConditionStateSchema
{
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
public const string StateTable = "alarm_condition_state";
/// <summary>Append-only table holding one row per comment-trail entry, keyed by alarm id + ordinal.</summary>
public const string CommentsTable = "alarm_condition_comment";
/// <summary>What <c>comments_json</c> holds once the trail lives in <see cref="CommentsTable"/>.</summary>
public const string EmptyInlineComments = "[]";
/// <summary>
/// Creates the state table if it does not already exist. Idempotent.
/// The one append statement for <see cref="CommentsTable"/>, shared by the migration and the
/// store. <c>OR IGNORE</c> is what makes the trail append-only: an ordinal already held is
/// never rewritten. Parameters: <c>@Id @Seq @Ts @User @Kind @Text</c>.
/// </summary>
public const string InsertCommentSql =
"""
INSERT OR IGNORE INTO alarm_condition_comment
(scripted_alarm_id, seq, timestamp_utc, user_name, kind, text)
VALUES (@Id, @Seq, @Ts, @User, @Kind, @Text)
""";
private static readonly JsonSerializerOptions InlineJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Creates the state table and the comment table if they do not already exist. Idempotent.
/// </summary>
/// <param name="connection">
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
@@ -55,10 +101,11 @@ public static class AlarmConditionStateSchema
// The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
// Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
// so they are NOT NULL. comments_json is never null either — an empty trail is the literal
// "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
// has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
// transition column).
// so they are NOT NULL. comments_json is never null either — it is the literal "[]" since the
// trail moved to alarm_condition_comment (an old-shape row may still carry an inline trail
// until the alarm's next save; MigrateInlineComments copies it without touching this row).
// The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm has no such history yet. updated_at_utc
// carries LastTransitionUtc (there is no dedicated transition column).
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS alarm_condition_state (
scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
@@ -76,7 +123,161 @@ public static class AlarmConditionStateSchema
comments_json TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alarm_condition_comment (
scripted_alarm_id TEXT NOT NULL,
seq INTEGER NOT NULL,
timestamp_utc TEXT NOT NULL,
user_name TEXT NOT NULL,
kind TEXT NOT NULL,
text TEXT NOT NULL,
PRIMARY KEY (scripted_alarm_id, seq)
);
""";
cmd.ExecuteNonQuery();
}
/// <summary>What one <see cref="MigrateInlineComments"/> run did.</summary>
/// <param name="RowsMoved">Condition rows for which at least one inline entry was copied across.</param>
/// <param name="UnparseableAlarmIds">
/// Alarms whose inline trail is not valid JSON and was left where it is — the caller logs these,
/// so an unmigratable history is visible rather than skipped silently on every boot.
/// </param>
public sealed record InlineCommentMigrationResult(int RowsMoved, IReadOnlyList<string> UnparseableAlarmIds);
/// <summary>
/// Copies every condition row's inline <c>comments_json</c> trail into
/// <see cref="CommentsTable"/>. Idempotent; it never writes the condition row itself.
/// </summary>
/// <remarks>
/// <para>
/// <b>Run it AFTER the tables are registered for replication</b> — it writes rows, and a
/// row written before the capture triggers exist never reaches the peer (the same
/// DDL → register → writes rule <c>AlarmSfLegacyMigrator</c> follows).
/// </para>
/// <para>
/// <b>Copy, don't clear.</b> Resetting <c>comments_json</c> here would be a full-row UPDATE
/// that re-stamps the condition row with a fresh HLC — during a pair's rolling upgrade
/// that stale-but-newly-stamped copy would beat a newer peer state (an ack made on the
/// already-upgraded node) under LWW (review m1). The column is left as it is: the store's
/// load merges an inline trail with the table by ordinal (the table wins), and the
/// engine's next save of that alarm writes <c>"[]"</c> with the alarm's real state.
/// </para>
/// <para>
/// Each entry lands at its ordinal in the inline array via <see cref="InsertCommentSql"/>,
/// so a re-run — or a row whose trail was already (partly) copied — neither duplicates nor
/// reorders anything and writes nothing for an ordinal already held. Each row copies in its
/// own transaction, so a crash leaves it fully copied or untouched. A row whose inline JSON
/// does not parse is left exactly as it is (the pre-split store could not load it either)
/// and reported in <see cref="InlineCommentMigrationResult.UnparseableAlarmIds"/> rather
/// than failing host start.
/// </para>
/// </remarks>
/// <param name="connection">An already-open connection from <c>ILocalDb.CreateConnection()</c>.</param>
/// <returns>What was copied, and which rows could not be read.</returns>
public static InlineCommentMigrationResult MigrateInlineComments(SqliteConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
var pending = new List<(string AlarmId, string Json)>();
using (var select = connection.CreateCommand())
{
select.CommandText =
"SELECT scripted_alarm_id, comments_json FROM alarm_condition_state WHERE comments_json <> @Empty";
select.Parameters.AddWithValue("@Empty", EmptyInlineComments);
using var reader = select.ExecuteReader();
while (reader.Read())
pending.Add((reader.GetString(0), reader.GetString(1)));
}
var migrated = 0;
var unparseable = new List<string>();
foreach (var (alarmId, json) in pending)
{
IReadOnlyList<InlineComment> entries;
try
{
entries = ParseInlineComments(json);
}
catch (JsonException)
{
unparseable.Add(alarmId);
continue;
}
var inserted = 0;
using var tx = connection.BeginTransaction();
for (var seq = 0; seq < entries.Count; seq++)
{
using var insert = connection.CreateCommand();
insert.Transaction = tx;
insert.CommandText = InsertCommentSql;
insert.Parameters.AddWithValue("@Id", alarmId);
insert.Parameters.AddWithValue("@Seq", seq);
insert.Parameters.AddWithValue("@Ts", FormatTimestamp(entries[seq].TimestampUtc));
insert.Parameters.AddWithValue("@User", entries[seq].User);
insert.Parameters.AddWithValue("@Kind", entries[seq].Kind);
insert.Parameters.AddWithValue("@Text", entries[seq].Text);
inserted += insert.ExecuteNonQuery();
}
tx.Commit();
if (inserted > 0)
migrated++;
}
return new InlineCommentMigrationResult(migrated, unparseable);
}
/// <summary>Formats a comment timestamp for <see cref="CommentsTable"/> (round-trip "O" text).</summary>
/// <param name="value">The timestamp.</param>
/// <returns>The round-trip text form.</returns>
public static string FormatTimestamp(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
/// <summary>Parses a <see cref="CommentsTable"/> timestamp, preserving its <see cref="DateTimeKind"/>.</summary>
/// <param name="value">The round-trip text form.</param>
/// <returns>The timestamp.</returns>
public static DateTime ParseTimestamp(string value) =>
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
/// <summary>
/// Parses a legacy inline <c>comments_json</c> trail exactly as the pre-split store did
/// (<see cref="JsonSerializerDefaults.Web"/>; null strings become empty).
/// </summary>
/// <param name="json">The column value.</param>
/// <returns>The entries in trail order; empty for a blank or <c>"[]"</c> column.</returns>
/// <exception cref="JsonException">The column is not a valid trail.</exception>
public static IReadOnlyList<InlineComment> ParseInlineComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
var dtos = JsonSerializer.Deserialize<List<InlineCommentDto>>(json, InlineJsonOptions);
if (dtos is null || dtos.Count == 0) return [];
return dtos
.Select(d => new InlineComment(
d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToList();
}
/// <summary>One comment-trail entry as stored — the persistence-side mirror of the engine's <c>AlarmComment</c>.</summary>
/// <param name="TimestampUtc">When the action happened.</param>
/// <param name="User">Identity of the actor.</param>
/// <param name="Kind">Classification (Acknowledge, Confirm, AddComment, …).</param>
/// <param name="Text">Operator-supplied or engine-generated text.</param>
public sealed record InlineComment(DateTime TimestampUtc, string User, string Kind, string Text);
/// <summary>The pre-split on-disk shape of one entry in <c>comments_json</c>.</summary>
private sealed class InlineCommentDto
{
/// <summary>When the comment was recorded.</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor.</summary>
public string? User { get; set; }
/// <summary>Classification of the entry.</summary>
public string? Kind { get; set; }
/// <summary>Entry text.</summary>
public string? Text { get; set; }
}
}