71379816e7
Moves the alarm store-and-forward buffer out of its own alarm-historian.db and into the node's consolidated LocalDb, where it replicates to the redundant pair peer. A node that dies holding undelivered alarm history no longer takes it to the grave. Tasks 2 and 3 land together, as the plan anticipated. They are not separable: the gate is a constructor argument of the rewritten sink, and a commit that replicated the queue without gating the drain would be a commit in which both nodes of a pair deliver every alarm event, continuously. That drain gate is the load-bearing part of the change, and the recon explains why it is new work rather than a refinement. Exactly-once delivery across a pair is enforced today on the ENQUEUE side, by HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is empty and its ungated drain has nothing to send. Replicating the table destroys that invariant. The gate is a Func<bool> the caller supplies, because the drain runs on a timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate verdict to -- the same verdict the inbound-write and native-ack gates use, so there is no second notion of am-I-the-Primary to drift. Two failure modes are deliberately closed: - The view is seeded OPEN, matching the policy's own answer for an unknown role with no driver peer. A deployment that runs no redundancy never publishes to it, and defaulting closed would silently stop its alarm history forever. - A gate that throws is read as not-now, never as permission, and a closed gate reports the new HistorianDrainState.NotPrimary rather than Idle. A Secondary's rising queue is supposed to look different from a stalled drain, and if BOTH nodes report NotPrimary the pair is misconfigured and says so instead of quietly filling toward the capacity ceiling. Row ids are a hash of the payload rather than fresh GUIDs. Both adapters accept the same fanned transition in the window before the first redundancy snapshot arrives, and under last-writer-wins an equal key converges those two accepts into one row instead of duplicating them. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
76 lines
4.9 KiB
JSON
76 lines
4.9 KiB
JSON
{
|
|
"Serilog": {
|
|
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
|
"WriteTo": [
|
|
{ "Name": "Console" },
|
|
{ "Name": "File", "Args": { "path": "logs/otopcua-.log", "rollingInterval": "Day" } }
|
|
]
|
|
},
|
|
"Security": {
|
|
"Auth": {
|
|
"DisableLogin": false
|
|
}
|
|
},
|
|
"Secrets": {
|
|
"SqlitePath": "otopcua-secrets.db",
|
|
"MasterKey": {
|
|
"Source": "Environment",
|
|
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
|
},
|
|
"RunMigrationsOnStartup": true,
|
|
"ResolveCacheTtl": "00:00:30",
|
|
"Replication": {
|
|
"_comment": "Peer-to-peer secret replication over the Akka cluster. OPT-IN: Enabled=false keeps the plain local SQLite store on every node. KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 — that version's ISecretReplicator registration is shadowed by the NoOp one AddZbSecrets registers first, so enabling this decorates the store but replicates nothing and starts no actor. Do not enable until the library is fixed. All nodes must also share the same KEK.",
|
|
"Enabled": false,
|
|
"AnnounceInterval": "00:00:30",
|
|
"ActorName": "zb-secret-replication"
|
|
}
|
|
},
|
|
"ServerHistorian": {
|
|
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
|
"Enabled": false,
|
|
"Endpoint": "",
|
|
"ApiKey": "",
|
|
"_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
|
|
"UseTls": true,
|
|
"AllowUntrustedServerCertificate": false,
|
|
"CaCertificatePath": null,
|
|
"CallTimeout": "00:00:30",
|
|
"MaxTieClusterOverfetch": 65536
|
|
},
|
|
"ContinuousHistorization": {
|
|
"_comment": "Continuous historization of driver (non-Galaxy) tag values: a crash-safe FasterLog outbox + recorder draining to the ServerHistorian gateway's WriteLiveValues. Disabled => no recorder is spawned. Requires ServerHistorian to be configured; the gateway connection (endpoint/key/TLS) is sourced from the ServerHistorian section, not here.",
|
|
"Enabled": false,
|
|
"OutboxPath": "",
|
|
"_OutboxPathComment": "Directory holding the FasterLog segment + commit files. Required when Enabled=true. In production set an ABSOLUTE path on durable storage.",
|
|
"CommitMode": "PerEntry",
|
|
"CommitIntervalMs": 100,
|
|
"DrainBatchSize": 64,
|
|
"DrainIntervalSeconds": 2,
|
|
"Capacity": 0,
|
|
"MinBackoffSeconds": 1,
|
|
"MaxBackoffSeconds": 30
|
|
},
|
|
"AlarmHistorian": {
|
|
"_comment": "Durable store-and-forward alarm sink. Buffers into the node's consolidated LocalDb (see the LocalDb section) so the queue replicates to the redundant pair peer, and drains alarm events to the ServerHistorian gateway's SendEvent path from whichever node holds the Primary role. The downstream connection (endpoint/key/TLS) is sourced from the ServerHistorian section.",
|
|
"Enabled": false,
|
|
"DrainIntervalSeconds": 5,
|
|
"Capacity": 1000000,
|
|
"DeadLetterRetentionDays": 30
|
|
},
|
|
"LocalDb": {
|
|
"_comment": "Node-local SQLite store caching the deployed-configuration artifact, so a driver node can boot from its last-known-good config when central SQL is unreachable. Registered on driver-role nodes only (see LocalDbRegistration). Storage is unconditional; replication to the redundant pair peer is default-OFF.",
|
|
"Path": "./data/otopcua-localdb.db",
|
|
"_SyncListenPortComment": "Port for the dedicated cleartext-HTTP/2 (h2c) listener serving the passive sync endpoint. 0 = no listener bound and the endpoint is not mapped. Must be a port distinct from the main HTTP listener: prior-knowledge h2c cannot share a cleartext Http1AndHttp2 port.",
|
|
"SyncListenPort": 0,
|
|
"Replication": {
|
|
"_comment": "Empty = passive/off. Set PeerAddress on exactly ONE node of the pair (the stream is bidirectional, so the other side still pushes its own deltas back). ApiKey must be BYTE-IDENTICAL on both nodes — the host interceptor fail-closes, so a typo silently stops convergence rather than degrading to unauthenticated. Supply it via ${secret:...} in production, never committed.",
|
|
"_MaxBatchSizeComment": "Row count, NOT bytes, against gRPC's 4 MB default message cap. Artifact chunk rows are ~171 KB base64, so keep this at 16 on the pair (16 x 171 KB ~= 2.7 MB worst case)."
|
|
}
|
|
},
|
|
"Deployment": {
|
|
"_comment": "R2-11 (05/CONV-2): deploy-gate TagConfig strictness. Warn (default) = non-blocking warnings logged + appended to the deployment result message; Error = a config with a typo'd enum or unparseable TagConfig is rejected at the draft gate. Running servers are untouched; the gate only sees re-deploys.",
|
|
"TagConfigValidationMode": "Warn"
|
|
}
|
|
}
|