Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs

346 lines
14 KiB
C#

#nullable enable
using System.Data;
using System.Data.Common;
using System.Text.RegularExpressions;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed GraphE INPUT_* write contract on one Oracle connection
/// and one transaction. Each operation takes the profile's exclusive table lock,
/// executes its bound command once, validates affected rows before commit, and
/// deliberately contains no retry loop.
/// </summary>
public sealed partial class OracleManualFinancialMutationExecutor
: IManualFinancialMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
public OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
{
}
internal OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
_commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle);
if (_commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The Oracle manual-financial command timeout must be between 1 and 600 seconds.");
}
}
public async Task<ManualFinancialDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
ManualFinancialDbTransaction transaction,
CancellationToken cancellationToken = default)
{
if (source != DataSourceKind.Oracle)
{
throw new ArgumentOutOfRangeException(
nameof(source),
source,
"Manual-financial GraphE records are stored in Oracle.");
}
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(DataSourceKind.Oracle);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
operationToken)
.ConfigureAwait(false);
await ExecuteLockAsync(
connection,
databaseTransaction,
transaction.ExclusiveTableLockSql,
operationToken).ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
var count = await ExecuteCommandAsync(
connection,
databaseTransaction,
commandSpec,
operationToken).ConfigureAwait(false);
if (!Matches(commandSpec.AffectedRowsRule, count))
{
throw Rejected(transaction.Kind, count);
}
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new ManualFinancialDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// The client cannot prove whether Oracle committed. Issuing a
// rollback or repeating the command would make the situation worse.
throw UnknownOutcome(transaction.Kind, exception);
}
var isCancellationOrTimeout = exception is OperationCanceledException ||
_errorDetector.IsTimeout(DataSourceKind.Oracle, exception);
var rollbackFailed = false;
if (databaseTransaction is not null)
{
rollbackFailed = !await TryRollbackAsync(databaseTransaction)
.ConfigureAwait(false);
}
if (databaseTransaction is not null && (isCancellationOrTimeout || rollbackFailed))
{
throw UnknownOutcome(transaction.Kind, exception);
}
if (exception is ManualFinancialMutationRejectedException rejected)
{
throw rejected;
}
if (exception is ManualFinancialMutationException mutationException)
{
throw mutationException;
}
throw new ManualFinancialMutationException(
$"The {transaction.Kind} Oracle transaction did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
if (databaseTransaction is not null)
{
await databaseTransaction.DisposeAsync().ConfigureAwait(false);
}
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
}
}
internal static void ConfigureOracleCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (command is not OracleCommand oracleCommand)
{
throw new DatabaseConfigurationException(
"The manual-financial mutation connection did not create an Oracle command.");
}
oracleCommand.BindByName = true;
}
private async Task ExecuteLockAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
Configure(command, transaction, sql);
// Execute exactly once. LOCK TABLE has no operator value and no retry.
_ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<int> ExecuteCommandAsync(
DbConnection connection,
DbTransaction transaction,
ManualFinancialDbCommand commandSpec,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
Configure(command, transaction, commandSpec.Sql);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once. There is deliberately no transient retry.
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private void Configure(DbCommand command, DbTransaction transaction, string sql)
{
command.CommandText = sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = _commandTimeoutSeconds;
command.Transaction = transaction;
_configureCommand(command);
}
private async Task<bool> TryRollbackAsync(DbTransaction transaction)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(_commandTimeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static void ValidateTransaction(ManualFinancialDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
var expected = transaction.Screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
(Table: "INPUT_PIE", Lock: "LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.GrowthMetrics =>
(Table: "INPUT_GROW", Lock: "LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.Sales =>
(Table: "INPUT_SELL", Lock: "LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.OperatingProfit =>
(Table: "INPUT_PROFIT", Lock: "LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE"),
_ => throw InvalidTransaction()
};
if (transaction.OperationId == Guid.Empty ||
!transaction.RequiresExclusiveTableLock ||
!string.Equals(transaction.TableName, expected.Table, StringComparison.Ordinal) ||
!string.Equals(transaction.ExclusiveTableLockSql, expected.Lock, StringComparison.Ordinal) ||
transaction.Commands.Count != 1 || transaction.Commands[0] is null)
{
throw InvalidTransaction();
}
var command = transaction.Commands[0];
var validShape = transaction.Kind switch
{
ManualFinancialMutationKind.Create =>
command.Kind == ManualFinancialDbCommandKind.Insert &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.Update =>
command.Kind == ManualFinancialDbCommandKind.Update &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.DeleteOne =>
command.Kind == ManualFinancialDbCommandKind.DeleteOne &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.DeleteAll =>
command.Kind == ManualFinancialDbCommandKind.DeleteAll &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.AnyNonNegative,
_ => false
};
if (!validShape || string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';'))
{
throw InvalidTransaction();
}
var placeholders = PlaceholderPattern()
.Matches(command.Sql)
.Select(static match => match.Groups[1].Value)
.ToArray();
var parameters = command.Parameters.ToArray();
if (parameters.Any(static parameter =>
parameter is null ||
parameter.DbType != DbType.String ||
parameter.Value is not string) ||
parameters.Select(static parameter => parameter.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count() != parameters.Length ||
!placeholders.SequenceEqual(
parameters.Select(static parameter => parameter.Name),
StringComparer.OrdinalIgnoreCase))
{
throw InvalidTransaction();
}
}
private static bool Matches(ManualFinancialAffectedRowsRule rule, int count) =>
rule switch
{
ManualFinancialAffectedRowsRule.ExactlyOne => count == 1,
ManualFinancialAffectedRowsRule.AnyNonNegative => count >= 0,
_ => false
};
private static ManualFinancialMutationRejectedException Rejected(
ManualFinancialMutationKind kind,
int affectedRows)
{
var reason = affectedRows is < 0 or > 1
? ManualFinancialMutationRejectionReason.AmbiguousIdentity
: kind == ManualFinancialMutationKind.Create
? ManualFinancialMutationRejectionReason.DuplicateIdentity
: ManualFinancialMutationRejectionReason.NotFoundOrChanged;
return new ManualFinancialMutationRejectedException(
reason,
$"The {kind} precondition failed and the Oracle transaction was rolled back.");
}
private static ManualFinancialMutationException UnknownOutcome(
ManualFinancialMutationKind kind,
Exception exception) =>
new(
$"The {kind} Oracle transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
private static ArgumentException InvalidTransaction() =>
new("The manual-financial transaction is invalid.", "transaction");
[GeneratedRegex(@":([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)]
private static partial Regex PlaceholderPattern();
}