Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/SecretsSqlServerConnectionFactory.cs
T
Joseph Doherty dd0a846b64 feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 04:08:23 -04:00

58 lines
2.3 KiB
C#

using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;
/// <summary>
/// Opens connections to the shared SQL-Server secret database and carries the settings every
/// command needs (schema name, command timeout).
/// </summary>
/// <param name="options">The validated SQL-Server store options.</param>
public sealed class SecretsSqlServerConnectionFactory(SqlServerSecretsOptions options)
{
private readonly SqlServerSecretsOptions _options =
options ?? throw new ArgumentNullException(nameof(options));
/// <summary>The bracket-quoted, allow-list-validated schema prefix for object names.</summary>
public string SchemaName => _options.SchemaName;
/// <summary>Command timeout, in whole seconds, for every command issued against this store.</summary>
public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds);
/// <summary>Opens a connection to the shared secret database.</summary>
/// <param name="cancellationToken">A token to cancel the connect.</param>
/// <returns>An opened connection. The caller owns disposal.</returns>
public async Task<SqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new SqlConnection(_options.ConnectionString);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Creates a command on <paramref name="connection"/> with the configured timeout applied.
/// </summary>
/// <param name="connection">The open connection.</param>
/// <param name="commandText">The command text.</param>
/// <param name="transaction">The ambient transaction, if any.</param>
/// <returns>The configured command. The caller owns disposal.</returns>
public SqlCommand CreateCommand(
SqlConnection connection, string commandText, SqlTransaction? transaction = null)
{
ArgumentNullException.ThrowIfNull(connection);
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
command.CommandTimeout = CommandTimeoutSeconds;
command.Transaction = transaction;
return command;
}
}