#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; /// /// 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. /// 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 _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 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 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(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 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 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(); }