346 lines
14 KiB
C#
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();
|
|
}
|