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; /// /// The onReady callback handed to AddZbLocalDb: creates the deployment-cache, /// alarm store-and-forward, and scripted-alarm condition-state tables and — when this node /// actually replicates — opts them into change capture. /// /// /// /// Public rather than internal only so LocalDbSetupTests 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 this method. /// /// public static class LocalDbSetup { /// /// The four tables that participate in replication — registered when this node is configured /// to replicate, and deregistered (stale capture triggers dropped) when it is not. /// /// /// One list shared by both branches on purpose. A table added to only the registration /// branch would replicate on a configured node and then never be cleaned off an /// unconfigured one; a table added to only the deregistration branch would be cleaned on a /// node that never registered it. /// private static readonly string[] ReplicatedTables = [ DeploymentCacheSchema.ArtifactsTable, DeploymentCacheSchema.PointerTable, AlarmSfSchema.EventsTable, AlarmConditionStateSchema.StateTable, ]; /// /// Initialises the local database: DDL first, then replication registration (or its removal). /// /// /// /// THE ORDER IS LOAD-BEARING: DDL → RegisterReplicated → writes. /// RegisterReplicated 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. therefore runs last, after every /// registration — it writes rows, and they must be captured like any other write. /// /// /// Registration is CONDITIONAL on . It used /// to be unconditional, on every driver node, replicating or not. That was not merely /// wasteful — an unreplicated node paid two INSERTs plus a json_object of the /// whole row inside every write transaction, for an oplog nothing ever drained — it also /// made baselineExistingRows unusable, because passing it on a node with no peer /// would flag a snapshot resync for a peer that does not exist. The ordering rule above /// is unaffected: when registration is skipped there is no capture to be too late for. /// /// /// The unconfigured branch actively cleans up rather than merely abstaining. /// DeregisterReplicated drops any capture triggers an earlier build left in the /// file and prunes that table's oplog and row-version rows. Declining to register alone /// would leave every file written by a pre-2026-08-18 build capturing forever — on /// precisely the node that has no peer to capture for. /// /// /// A failure part-way through either loop fails host start, and that is deliberate. /// Neither branch catches, so a throw on the third of four tables leaves two flipped and /// propagates out of the AddZbLocalDb singleton factory — a node that came up /// anyway would be capturing (or not) on an arbitrary subset, which is silently partial /// replication with nothing in the log to say so. Both library calls are idempotent, so /// the retried boot simply completes the flip rather than compounding it. /// /// /// The alarm buffer's tables are created unconditionally, regardless of whether this /// node has AlarmHistorian:Enabled set, and regardless of replication. Creating /// one 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. /// /// /// The freshly constructed local database. /// /// Application configuration, read for the replication predicate and 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. /// /// /// Where the deregistration self-heal reports what it cleaned. Defaults to Serilog's static /// logger, which is what production uses: this runs inside the AddZbLocalDb singleton /// factory, where resolving an ILogger would nest a service resolution inside a /// singleton construction. Program.cs assigns Serilog.Log.Logger from the DI /// root logger before anything resolves ILocalDb. The parameter exists so tests can /// observe the line rather than infer it. /// public static void OnReady(ILocalDb db, IConfiguration configuration, Serilog.ILogger? logger = null) { 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); AlarmConditionStateSchema.Apply(connection); } if (ReplicationIsConfigured(configuration)) RegisterForCapture(db); else RemoveStaleCapture(db, logger ?? Serilog.Log.Logger); // LAST, and only here. This is the one call in OnReady that writes rows. AlarmSfLegacyMigrator.Migrate(db, configuration); } /// /// Whether this node participates in LocalDb replication, and therefore needs the capture /// triggers at all. /// /// /// /// Either key counts, and the OR is not belt-and-braces — it is the only predicate /// that is true on BOTH halves of a replicating pair. Replication is one /// bidirectional stream that exactly one side dials, so only the initiator sets /// LocalDb:Replication:PeerAddress; the passive node carries the shared /// ApiKey and a LocalDb:SyncListenPort and nothing else. (The docker-dev /// rig is the reference: site-a-1 has PeerAddress + ApiKey, site-a-2 has /// ApiKey + SyncListenPort, and central / site-b carry no Replication section at /// all.) Keying on PeerAddress alone would strip capture from every passive node — its /// local writes would stop reaching the initiator and the pair would converge in one /// direction only, silently. /// /// /// LocalDb:SyncListenPort deliberately does NOT join the OR. The passive /// endpoint is fail-closed on the key: with a port but no ApiKey, /// LocalDbSyncAuthInterceptor refuses every inbound stream, so such a node /// replicates nothing and has nothing to capture for. Adding the port to the predicate /// would install capture triggers on a node whose sync endpoint rejects all callers. /// LocalDbReplicationHealthCheck deliberately asks a different question — /// "was this node MEANT to replicate?", which is PeerAddress OR a bound listen /// port — so a port-without-key node correctly reports Degraded there while correctly /// capturing nothing here. The two predicates diverge only on that misconfiguration, /// and each is right about its own question. /// /// /// The keys are read straight from configuration rather than through /// IOptions<ReplicationOptions> because this runs inside the /// AddZbLocalDb factory, where resolving another option would nest a service /// resolution inside a singleton construction. The section path is /// — the same constant /// AddZbLocalDbReplication binds — so the predicate and the engine cannot /// disagree about which node replicates. /// /// /// Both directions of a change to this predicate are handled at boot, and both /// require the PAIR to be changed together. The sync handshake compares /// registered-table digests fail-closed, so a node that drops a table its peer still /// replicates stops syncing with a schema mismatch. Enable (or disable) replication on /// both nodes and restart both — never one. /// /// /// The application configuration. /// true when this node is configured to replicate. public static bool ReplicationIsConfigured(IConfiguration configuration) { // Shared with the alarm-historian drain gate (#603): one predicate, so the nodes that capture // changes and the nodes that stand down for a peer can never disagree. return Runtime.LocalDbReplicationPolicy.IsConfigured(configuration); } /// Opts every replicated table into change capture, baselining what is already there. /// /// /// baselineExistingRows: true is what makes turning replication ON for a node that /// has been running WITHOUT it actually converge. Capture is change-data-capture and the /// snapshot streamer pages from __localdb_row_version, so rows written before the /// first registration are in neither: a late opt-in used to replicate only writes made /// after the restart, silently and forever. Baselining seeds the ledger at the LWW floor /// (HLC 0, this node's id) and flags a snapshot resync, so a baselined row loses to any /// genuine remote write of the same key and wins only where the peer has no version at /// all. It is idempotent, and therefore free on every boot after the first — which is /// why it is unconditional here rather than behind a one-shot flag. /// /// /// ⚠️ On the pinned LocalDb 0.2.1 the snapshot flag is per-DATABASE, not per-table. /// Baselining anything flags a resync that streams every registered table in full, /// and on a pair enabling replication late both nodes do that to each other at once — the /// largest transfer the library performs. Bounded, but expect one slow first sync, and /// trim alarm_sf_events before enabling replication on a node that has been /// buffering alarm history for a long time. /// /// /// The per-table loop is kept rather than collapsed into a hypothetical bulk call: the /// library's contract is per-table (PK validation, digest, and — from LocalDb 0.3.0 — the /// snapshot flag itself), so a bulk API would only be able to give back the coarse /// behaviour. /// /// private static void RegisterForCapture(ILocalDb db) { foreach (var table in ReplicatedTables) db.RegisterReplicated(table, baselineExistingRows: true); } /// Drops capture this node must not pay for, and says so once if it found any. /// /// DeregisterReplicated is idempotent and deliberately usable on a table this process /// never registered, so a never-replicated file simply reports nothing to clean and stays /// silent. Symmetry is not a concern on this branch: this node has no peer, so there is no /// handshake digest for a one-sided deregistration to fail — and re-enabling later is safe /// despite the ledger prune, because baselines. /// private static void RemoveStaleCapture(ILocalDb db, Serilog.ILogger logger) { var cleaned = 0; foreach (var table in ReplicatedTables) { if (db.DeregisterReplicated(table)) cleaned++; } // Only when something was actually removed. Every default-OFF node in the fleet takes this // branch on every boot; logging unconditionally would be a daily non-event, which is how the // one boot that DID silently stop capturing gets overlooked. if (cleaned > 0) { logger.Information( "LocalDb: removed stale change-capture from {TableCount} table(s) — this node has no " + "replication configured ({PeerAddressKey}/{ApiKeyKey} are both absent — unset or blank), " + "so the capture " + "triggers an earlier build installed have been dropped and their oplog/row-version " + "rows pruned.", cleaned, $"{LocalDbRegistration.ReplicationSectionPath}:PeerAddress", $"{LocalDbRegistration.ReplicationSectionPath}:ApiKey"); } } }