Complete legacy operator UI and playout migration
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
#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();
|
||||
}
|
||||
Reference in New Issue
Block a user