Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
T
Joseph Doherty af545efdf5 feat(localdb): one-time alarm-historian.db migrator
Copies the pre-consolidation store-and-forward queue into the consolidated
database on first boot, then renames the legacy file aside. Rows are in that
file precisely because the historian could not be reached, so dropping them
on upgrade would discard exactly the alarm audit trail the queue exists to
protect.

Runs last in OnReady, after every RegisterReplicated call. It is the only
thing in OnReady that writes rows, and capture is trigger-based: a migration
that ran before registration would recover the backlog locally and never
replicate a line of it, silently and permanently.

Ids are derived from the payload rather than the plan's mig-{node}-{legacyId}
scheme. Node-prefixing solves the collision the legacy AUTOINCREMENT key
would cause -- node A's row 7 and node B's row 7 are different alarms -- but
it preserves a duplication that should be collapsed instead. A warm pair's
two legacy files OVERLAP: HistorianAdapterActor default-writes while its
redundancy role is unknown, so both nodes accepted the same transitions
during every boot window. Prefixed ids would carry those duplicates into the
merged buffer forever; equal-payload ids converge them. The same property
makes a crash between commit and rename harmless under INSERT OR IGNORE.

OnReady now takes IConfiguration rather than offering an overload that skips
the migration. A wiring mistake that silently discarded a node's undelivered
alarm history is not a mistake worth making possible.

The copy is restricted to the columns the legacy table actually has. Naming
a column an older build never wrote throws "no such column", which would
discard every row in the table rather than the one field.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 04:31:10 -04:00

69 lines
3.4 KiB
C#

using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache and
/// alarm store-and-forward tables and opts them into replication.
/// </summary>
/// <remarks>
/// <para>
/// Public rather than internal only so <c>LocalDbSetupTests</c> can drive the production
/// callback directly. Initialising a test database from a hand-written copy of this schema
/// would prove only that the test agrees with itself — the whole value of those tests is
/// that they exercise <b>this</b> method.
/// </para>
/// </remarks>
public static class LocalDbSetup
{
/// <summary>
/// Initialises the local database: DDL first, then replication registration.
/// </summary>
/// <remarks>
/// <para>
/// <b>THE ORDER IS LOAD-BEARING: DDL → RegisterReplicated → writes.</b>
/// <c>RegisterReplicated</c> is what installs the three AFTER triggers that capture
/// changes into the oplog. Any row written before that call is never captured, so it
/// never reaches the peer — silently, and permanently, because nothing ever revisits
/// history. <see cref="AlarmSfLegacyMigrator"/> therefore runs <b>last</b>, after every
/// registration — it writes rows, and they must be captured like any other write.
/// </para>
/// <para>
/// The alarm buffer's tables are created unconditionally, regardless of whether this
/// node has <c>AlarmHistorian:Enabled</c> set. An empty registered table costs three
/// triggers and nothing else, whereas creating it lazily when the sink first appears
/// would mean a node that enables the historian later writes rows before its triggers
/// exist — which is precisely the silent-loss shape above.
/// </para>
/// </remarks>
/// <param name="db">The freshly constructed local database.</param>
/// <param name="configuration">
/// Application configuration, read by the legacy migrator for the pre-consolidation queue's
/// path. Required rather than optional: an overload that silently skipped the migration
/// would be one wiring mistake away from discarding a node's undelivered alarm history.
/// </param>
public static void OnReady(ILocalDb db, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(db);
ArgumentNullException.ThrowIfNull(configuration);
// CreateConnection() hands back an already-open, pragma-configured connection carrying the
// zb_hlc_next() UDF the capture triggers need. Calling Open() on it would throw.
using (var connection = db.CreateConnection())
{
DeploymentCacheSchema.Apply(connection);
AlarmSfSchema.Apply(connection);
}
db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
db.RegisterReplicated(AlarmSfSchema.EventsTable);
// LAST, and only here. This is the one call in OnReady that writes rows.
AlarmSfLegacyMigrator.Migrate(db, configuration);
}
}