Merge commit '82380d2b' into remed/integration-w3
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user