feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal enum DevelopmentDatabaseSmokeStage
|
||||
{
|
||||
None,
|
||||
Preflight,
|
||||
Create,
|
||||
CreateReadback,
|
||||
Save,
|
||||
SaveReadback,
|
||||
Cleanup,
|
||||
VerifyAbsent
|
||||
}
|
||||
|
||||
internal sealed record DevelopmentDatabaseSmokeResult(
|
||||
string ScenarioName,
|
||||
bool Passed,
|
||||
DevelopmentDatabaseSmokeStage FailureStage,
|
||||
string FailureClass,
|
||||
bool OutcomeUnknown,
|
||||
bool CleanupAttempted,
|
||||
bool CleanupVerified,
|
||||
bool ManualCleanupMayBeRequired);
|
||||
|
||||
/// <summary>
|
||||
/// Runs one collision-isolated development write scenario. Every delegate is
|
||||
/// invoked at most once. A mutation with an unknown outcome quarantines the
|
||||
/// scenario immediately: no cleanup mutation or later scenario may be issued.
|
||||
/// </summary>
|
||||
internal sealed class DevelopmentDatabaseSmokeScenario
|
||||
{
|
||||
private readonly Func<CancellationToken, Task> _preflight;
|
||||
private readonly Func<CancellationToken, Task> _create;
|
||||
private readonly Func<CancellationToken, Task> _verifyCreate;
|
||||
private readonly Func<CancellationToken, Task>? _save;
|
||||
private readonly Func<CancellationToken, Task>? _verifySave;
|
||||
private readonly Func<CancellationToken, Task> _cleanup;
|
||||
private readonly Func<CancellationToken, Task> _verifyAbsent;
|
||||
private readonly Func<Exception, bool> _isOutcomeUnknown;
|
||||
|
||||
internal DevelopmentDatabaseSmokeScenario(
|
||||
string name,
|
||||
Func<CancellationToken, Task> preflight,
|
||||
Func<CancellationToken, Task> create,
|
||||
Func<CancellationToken, Task> verifyCreate,
|
||||
Func<CancellationToken, Task>? save,
|
||||
Func<CancellationToken, Task>? verifySave,
|
||||
Func<CancellationToken, Task> cleanup,
|
||||
Func<CancellationToken, Task> verifyAbsent,
|
||||
Func<Exception, bool> isOutcomeUnknown)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
if ((save is null) != (verifySave is null))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Save and save-readback delegates must either both be present or both be absent.",
|
||||
nameof(save));
|
||||
}
|
||||
|
||||
Name = name;
|
||||
_preflight = preflight ?? throw new ArgumentNullException(nameof(preflight));
|
||||
_create = create ?? throw new ArgumentNullException(nameof(create));
|
||||
_verifyCreate = verifyCreate ?? throw new ArgumentNullException(nameof(verifyCreate));
|
||||
_save = save;
|
||||
_verifySave = verifySave;
|
||||
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
|
||||
_verifyAbsent = verifyAbsent ?? throw new ArgumentNullException(nameof(verifyAbsent));
|
||||
_isOutcomeUnknown = isOutcomeUnknown ??
|
||||
throw new ArgumentNullException(nameof(isOutcomeUnknown));
|
||||
}
|
||||
|
||||
internal string Name { get; }
|
||||
|
||||
internal async Task<DevelopmentDatabaseSmokeResult> RunAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var currentStage = DevelopmentDatabaseSmokeStage.Preflight;
|
||||
Exception? primaryFailure = null;
|
||||
var failureStage = DevelopmentDatabaseSmokeStage.None;
|
||||
var outcomeUnknown = false;
|
||||
var failureClass = "none";
|
||||
var createKnownCommitted = false;
|
||||
var cleanupAttempted = false;
|
||||
var cleanupVerified = false;
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await _preflight(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
currentStage = DevelopmentDatabaseSmokeStage.Create;
|
||||
await _create(cancellationToken).ConfigureAwait(false);
|
||||
createKnownCommitted = true;
|
||||
|
||||
currentStage = DevelopmentDatabaseSmokeStage.CreateReadback;
|
||||
await _verifyCreate(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_save is not null)
|
||||
{
|
||||
currentStage = DevelopmentDatabaseSmokeStage.Save;
|
||||
await _save(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
currentStage = DevelopmentDatabaseSmokeStage.SaveReadback;
|
||||
await _verifySave!(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
primaryFailure = exception;
|
||||
failureStage = currentStage;
|
||||
failureClass = RedactedDatabaseFailureClassifier.Classify(exception);
|
||||
outcomeUnknown = _isOutcomeUnknown(exception);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A known successful create is cleaned up even when a later read-only
|
||||
// verification fails. An unknown mutation outcome is quarantined and
|
||||
// never followed by another mutation.
|
||||
if (createKnownCommitted && !outcomeUnknown)
|
||||
{
|
||||
cleanupAttempted = true;
|
||||
try
|
||||
{
|
||||
currentStage = DevelopmentDatabaseSmokeStage.Cleanup;
|
||||
await _cleanup(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
currentStage = DevelopmentDatabaseSmokeStage.VerifyAbsent;
|
||||
await _verifyAbsent(CancellationToken.None).ConfigureAwait(false);
|
||||
cleanupVerified = true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
primaryFailure = exception;
|
||||
failureStage = currentStage;
|
||||
failureClass = RedactedDatabaseFailureClassifier.Classify(exception);
|
||||
outcomeUnknown = _isOutcomeUnknown(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryFailure is null && cleanupVerified)
|
||||
{
|
||||
return new DevelopmentDatabaseSmokeResult(
|
||||
Name,
|
||||
Passed: true,
|
||||
DevelopmentDatabaseSmokeStage.None,
|
||||
"none",
|
||||
OutcomeUnknown: false,
|
||||
CleanupAttempted: true,
|
||||
CleanupVerified: true,
|
||||
ManualCleanupMayBeRequired: false);
|
||||
}
|
||||
|
||||
return new DevelopmentDatabaseSmokeResult(
|
||||
Name,
|
||||
Passed: false,
|
||||
failureStage,
|
||||
failureClass,
|
||||
outcomeUnknown,
|
||||
cleanupAttempted,
|
||||
cleanupVerified,
|
||||
ManualCleanupMayBeRequired: outcomeUnknown &&
|
||||
(createKnownCommitted || failureStage == DevelopmentDatabaseSmokeStage.Create));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal static class DevelopmentDatabaseWriteSmokeApp
|
||||
{
|
||||
internal const int SuccessExitCode = 0;
|
||||
internal const int FailureExitCode = 1;
|
||||
internal const int NotAcknowledgedExitCode = 2;
|
||||
internal const int OutcomeUnknownExitCode = 3;
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
IReadOnlyList<string> arguments,
|
||||
Func<string?, IReadOnlyList<DevelopmentDatabaseSmokeScenario>> createPlan,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
ArgumentNullException.ThrowIfNull(createPlan);
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
|
||||
if (!SmokeCommandLine.TryParse(arguments, out var options))
|
||||
{
|
||||
await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false);
|
||||
return NotAcknowledgedExitCode;
|
||||
}
|
||||
|
||||
if (options.ShowHelp)
|
||||
{
|
||||
await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false);
|
||||
await output.WriteLineAsync(
|
||||
"No write occurs unless the exact development-database acknowledgement is present.")
|
||||
.ConfigureAwait(false);
|
||||
return SuccessExitCode;
|
||||
}
|
||||
|
||||
if (!options.IsWriteAcknowledged)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"BLOCKED: development database writes were not acknowledged; no configuration was loaded and no database call was made.")
|
||||
.ConfigureAwait(false);
|
||||
await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false);
|
||||
return NotAcknowledgedExitCode;
|
||||
}
|
||||
|
||||
IReadOnlyList<DevelopmentDatabaseSmokeScenario> plan;
|
||||
try
|
||||
{
|
||||
plan = createPlan(options.ConfigurationPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"FAIL: configuration or smoke-plan creation failed (details redacted).")
|
||||
.ConfigureAwait(false);
|
||||
return FailureExitCode;
|
||||
}
|
||||
|
||||
await output.WriteLineAsync(
|
||||
"START: isolated development DB write/readback/cleanup smoke; credentials, SQL, identifiers, and values are redacted.")
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var scenario in plan)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await output.WriteLineAsync("RUN: " + scenario.Name).ConfigureAwait(false);
|
||||
|
||||
DevelopmentDatabaseSmokeResult result;
|
||||
try
|
||||
{
|
||||
result = await scenario.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"FAIL: unclassified smoke failure; no retry was attempted (details redacted).")
|
||||
.ConfigureAwait(false);
|
||||
return FailureExitCode;
|
||||
}
|
||||
|
||||
if (result.Passed)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"PASS: create/save, fresh readback, cleanup, and absence verification completed.")
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
await output.WriteLineAsync(
|
||||
$"FAIL: stage={result.FailureStage}; failureClass={result.FailureClass}; cleanupAttempted={result.CleanupAttempted}; cleanupVerified={result.CleanupVerified}; details redacted.")
|
||||
.ConfigureAwait(false);
|
||||
if (result.OutcomeUnknown)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"QUARANTINED: mutation outcome is unknown; no retry, cleanup mutation, or later scenario was issued. A uniquely prefixed record may require manual inspection.")
|
||||
.ConfigureAwait(false);
|
||||
return OutcomeUnknownExitCode;
|
||||
}
|
||||
|
||||
return FailureExitCode;
|
||||
}
|
||||
|
||||
await output.WriteLineAsync(
|
||||
"PASS: every development DB write scenario was verified absent after cleanup.")
|
||||
.ConfigureAwait(false);
|
||||
return SuccessExitCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
/// <summary>
|
||||
/// The legacy deletion workflows remove child rows by numeric parent key. This
|
||||
/// read-only guard proves a newly suggested key has no pre-existing orphaned
|
||||
/// children before a smoke record can adopt that key.
|
||||
/// </summary>
|
||||
internal sealed class ExistingChildRowGuard
|
||||
{
|
||||
private const string ResultColumn = "ROW_COUNT";
|
||||
|
||||
private readonly IDataQueryExecutor _executor;
|
||||
|
||||
internal ExistingChildRowGuard(IDataQueryExecutor executor)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
}
|
||||
|
||||
internal Task EnsureNamedPlaylistChildrenAbsentAsync(
|
||||
string programCode,
|
||||
CancellationToken cancellationToken) =>
|
||||
EnsureZeroAsync(
|
||||
DataSourceKind.Oracle,
|
||||
"DB_WRITE_SMOKE_PLAYLIST_CHILD_PREFLIGHT",
|
||||
"SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM PLAY_LIST WHERE PG_CODE = :parent_code",
|
||||
programCode,
|
||||
cancellationToken);
|
||||
|
||||
internal Task EnsureThemeChildrenAbsentAsync(
|
||||
ThemeMarket market,
|
||||
string themeCode,
|
||||
CancellationToken cancellationToken) =>
|
||||
market switch
|
||||
{
|
||||
ThemeMarket.Krx => EnsureZeroAsync(
|
||||
DataSourceKind.Oracle,
|
||||
"DB_WRITE_SMOKE_KRX_THEME_CHILD_PREFLIGHT",
|
||||
"SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM SB_ITEM WHERE SB_M_CODE = :parent_code",
|
||||
themeCode,
|
||||
cancellationToken),
|
||||
ThemeMarket.Nxt => EnsureZeroAsync(
|
||||
DataSourceKind.MariaDb,
|
||||
"DB_WRITE_SMOKE_NXT_THEME_CHILD_PREFLIGHT",
|
||||
"SELECT CAST(COUNT(*) AS CHAR) ROW_COUNT FROM SB_ITEM WHERE SB_M_CODE = @parent_code",
|
||||
themeCode,
|
||||
cancellationToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market))
|
||||
};
|
||||
|
||||
internal Task EnsureExpertChildrenAbsentAsync(
|
||||
string expertCode,
|
||||
CancellationToken cancellationToken) =>
|
||||
EnsureZeroAsync(
|
||||
DataSourceKind.Oracle,
|
||||
"DB_WRITE_SMOKE_EXPERT_CHILD_PREFLIGHT",
|
||||
"SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM RECOMMEND_LIST WHERE RC_CODE = :parent_code",
|
||||
expertCode,
|
||||
cancellationToken);
|
||||
|
||||
private async Task EnsureZeroAsync(
|
||||
DataSourceKind source,
|
||||
string queryName,
|
||||
string sql,
|
||||
string parentCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(parentCode);
|
||||
var spec = new DataQuerySpec(
|
||||
sql,
|
||||
[new DataQueryParameter("parent_code", parentCode, DbType.String)]);
|
||||
spec.ValidateFor(source);
|
||||
var table = await _executor.ExecuteAsync(
|
||||
source,
|
||||
queryName,
|
||||
spec,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (table is null ||
|
||||
table.Columns.Count != 1 ||
|
||||
table.Rows.Count != 1 ||
|
||||
!string.Equals(
|
||||
table.Columns[0].ColumnName,
|
||||
ResultColumn,
|
||||
StringComparison.Ordinal) ||
|
||||
table.Columns[0].DataType != typeof(string) ||
|
||||
table.Rows[0][0] is not string count ||
|
||||
!string.Equals(count, "0", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A suggested numeric key has existing child rows or an invalid preflight result.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AssemblyName>MBN_STOCK_WEBVIEW.DbWriteSmoke</AssemblyName>
|
||||
<RootNamespace>MBN_STOCK_WEBVIEW.DbWriteSmoke</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,172 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal static partial class OwnedManualSmokeResidueRecovery
|
||||
{
|
||||
internal const string RecoveryOption = "--reconcile-known-owned-manual-residue";
|
||||
|
||||
private static readonly IReadOnlyDictionary<ManualFinancialScreenKind, char> ScenarioCodes =
|
||||
new Dictionary<ManualFinancialScreenKind, char>
|
||||
{
|
||||
[ManualFinancialScreenKind.RevenueComposition] = 'P',
|
||||
[ManualFinancialScreenKind.GrowthMetrics] = 'G',
|
||||
[ManualFinancialScreenKind.Sales] = 'S',
|
||||
[ManualFinancialScreenKind.OperatingProfit] = 'O'
|
||||
};
|
||||
|
||||
internal static bool IsRequested(IReadOnlyList<string> arguments) =>
|
||||
arguments.Contains(RecoveryOption, StringComparer.Ordinal);
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
IReadOnlyList<string> arguments,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
if (!TryParse(arguments, out var configurationPath))
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"Usage: MBN_STOCK_WEBVIEW.DbWriteSmoke " + RecoveryOption + " " +
|
||||
SmokeCommandLine.WriteAcknowledgement +
|
||||
" [" + SmokeCommandLine.ConfigurationOption + " <database.local.json>]")
|
||||
.ConfigureAwait(false);
|
||||
return DevelopmentDatabaseWriteSmokeApp.NotAcknowledgedExitCode;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var options = new DatabaseOptionsLoader().Load(configurationPath);
|
||||
options.Resilience.MaximumRetryCount = 0;
|
||||
options.Resilience.InitialRetryDelayMilliseconds = 0;
|
||||
var runtime = DatabaseRuntime.Create(options);
|
||||
var service = new LegacyManualFinancialScreenService(
|
||||
runtime.Executor,
|
||||
new OracleManualFinancialMutationExecutor(
|
||||
runtime.ConnectionFactory,
|
||||
options.Resilience));
|
||||
var prefix = SmokeRunIdentity.Prefix +
|
||||
DateTimeOffset.UtcNow.ToString("yyMMdd", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var total = 0;
|
||||
|
||||
await output.WriteLineAsync(
|
||||
"START: reconcile only the closed, current-UTC-date manual smoke namespace; details redacted.")
|
||||
.ConfigureAwait(false);
|
||||
foreach (var (screen, scenarioCode) in ScenarioCodes)
|
||||
{
|
||||
var result = await service.SearchAsync(
|
||||
screen,
|
||||
prefix,
|
||||
LegacyManualFinancialScreenService.MaximumResults,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var owned = result.Items.Where(item =>
|
||||
IsOwned(item.Record.Identity.StockName, prefix, scenarioCode))
|
||||
.ToArray();
|
||||
if (result.IsTruncated || owned.Length > 1)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"BLOCKED: the owned residue set was not singular and complete; writesAttempted=false.")
|
||||
.ConfigureAwait(false);
|
||||
return DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
|
||||
}
|
||||
|
||||
if (owned.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await service.DeleteAsync(owned[0], cancellationToken).ConfigureAwait(false);
|
||||
total++;
|
||||
try
|
||||
{
|
||||
_ = await service.GetAsync(
|
||||
owned[0].Record.Identity,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (ManualFinancialNotFoundException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await output.WriteLineAsync(
|
||||
"FAIL: an owned residue remained after one delete; no retry was issued.")
|
||||
.ConfigureAwait(false);
|
||||
return DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
|
||||
}
|
||||
|
||||
await output.WriteLineAsync(
|
||||
$"PASS: reconciled={total}; every owned residue is absent; no retry was issued.")
|
||||
.ConfigureAwait(false);
|
||||
return DevelopmentDatabaseWriteSmokeApp.SuccessExitCode;
|
||||
}
|
||||
catch (ManualFinancialMutationException exception)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"FAIL: failureClass=" + RedactedDatabaseFailureClassifier.Classify(exception) +
|
||||
"; outcomeUnknown=" + exception.OutcomeUnknown.ToString().ToLowerInvariant() +
|
||||
"; no retry was issued.")
|
||||
.ConfigureAwait(false);
|
||||
return exception.OutcomeUnknown
|
||||
? DevelopmentDatabaseWriteSmokeApp.OutcomeUnknownExitCode
|
||||
: DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
"FAIL: failureClass=" + RedactedDatabaseFailureClassifier.Classify(exception) +
|
||||
"; no retry was issued; details redacted.")
|
||||
.ConfigureAwait(false);
|
||||
return DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOwned(string value, string prefix, char scenarioCode) =>
|
||||
value.Length == 20 &&
|
||||
value.StartsWith(prefix, StringComparison.Ordinal) &&
|
||||
value[^1] == scenarioCode &&
|
||||
OwnedNamePattern().IsMatch(value);
|
||||
|
||||
private static bool TryParse(
|
||||
IReadOnlyList<string> arguments,
|
||||
out string? configurationPath)
|
||||
{
|
||||
configurationPath = null;
|
||||
var recovery = false;
|
||||
var acknowledged = false;
|
||||
for (var index = 0; index < arguments.Count; index++)
|
||||
{
|
||||
switch (arguments[index])
|
||||
{
|
||||
case RecoveryOption when !recovery:
|
||||
recovery = true;
|
||||
break;
|
||||
case SmokeCommandLine.WriteAcknowledgement when !acknowledged:
|
||||
acknowledged = true;
|
||||
break;
|
||||
case SmokeCommandLine.ConfigurationOption when configurationPath is null &&
|
||||
index + 1 < arguments.Count:
|
||||
configurationPath = arguments[++index];
|
||||
if (string.IsNullOrWhiteSpace(configurationPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return recovery && acknowledged;
|
||||
}
|
||||
|
||||
[GeneratedRegex("^MBW[0-9]{6}[0-9A-F]{10}[PGSO]$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex OwnedNamePattern();
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
#nullable enable
|
||||
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal static class ProductionDevelopmentDatabaseSmokePlan
|
||||
{
|
||||
internal static IReadOnlyList<DevelopmentDatabaseSmokeScenario> Create(
|
||||
string? configurationPath)
|
||||
{
|
||||
var options = new DatabaseOptionsLoader().Load(configurationPath);
|
||||
|
||||
// This executable never inherits the production read retry count. A
|
||||
// timeout is surfaced once so the harness cannot conceal an uncertain
|
||||
// state transition behind another attempt.
|
||||
options.Resilience.MaximumRetryCount = 0;
|
||||
options.Resilience.InitialRetryDelayMilliseconds = 0;
|
||||
var runtime = DatabaseRuntime.Create(options);
|
||||
|
||||
var manualMutation = new OracleManualFinancialMutationExecutor(
|
||||
runtime.ConnectionFactory,
|
||||
options.Resilience);
|
||||
var namedMutation = new OracleNamedPlaylistMutationExecutor(
|
||||
runtime.ConnectionFactory,
|
||||
options.Resilience);
|
||||
var catalogMutation = new OperatorCatalogMutationExecutor(
|
||||
runtime.ConnectionFactory,
|
||||
options.Resilience);
|
||||
|
||||
var manual = new LegacyManualFinancialScreenService(
|
||||
runtime.Executor,
|
||||
manualMutation);
|
||||
var named = new LegacyNamedPlaylistPersistenceService(
|
||||
runtime.Executor,
|
||||
namedMutation);
|
||||
var themePersistence = new LegacyThemeCatalogPersistenceService(
|
||||
runtime.Executor,
|
||||
catalogMutation);
|
||||
var themeSelection = new LegacyThemeSelectionService(runtime.Executor);
|
||||
var expertPersistence = new LegacyExpertCatalogPersistenceService(
|
||||
runtime.Executor,
|
||||
catalogMutation);
|
||||
var expertSelection = new LegacyExpertSelectionService(runtime.Executor);
|
||||
var childGuard = new ExistingChildRowGuard(runtime.Executor);
|
||||
|
||||
var run = SmokeRunIdentity.Create(DateTimeOffset.UtcNow, Guid.NewGuid());
|
||||
return
|
||||
[
|
||||
CreateManualScenario(
|
||||
manual,
|
||||
CreateManualRecord(ManualFinancialScreenKind.RevenueComposition, run.For("G_PIE"))),
|
||||
CreateManualScenario(
|
||||
manual,
|
||||
CreateManualRecord(ManualFinancialScreenKind.GrowthMetrics, run.For("G_GROW"))),
|
||||
CreateManualScenario(
|
||||
manual,
|
||||
CreateManualRecord(ManualFinancialScreenKind.Sales, run.For("G_SELL"))),
|
||||
CreateManualScenario(
|
||||
manual,
|
||||
CreateManualRecord(ManualFinancialScreenKind.OperatingProfit, run.For("G_PROFIT"))),
|
||||
CreateNamedPlaylistScenario(named, childGuard, run.For("PLAYLIST")),
|
||||
CreateThemeScenario(
|
||||
themePersistence,
|
||||
themeSelection,
|
||||
childGuard,
|
||||
ThemeMarket.Krx,
|
||||
run.For("THEME_KRX")),
|
||||
CreateThemeScenario(
|
||||
themePersistence,
|
||||
themeSelection,
|
||||
childGuard,
|
||||
ThemeMarket.Nxt,
|
||||
run.For("THEME_NXT")),
|
||||
CreateExpertScenario(
|
||||
expertPersistence,
|
||||
expertSelection,
|
||||
childGuard,
|
||||
run.For("EXPERT"))
|
||||
];
|
||||
}
|
||||
|
||||
private static DevelopmentDatabaseSmokeScenario CreateManualScenario(
|
||||
IManualFinancialScreenService service,
|
||||
ManualFinancialRecord expectedRecord)
|
||||
{
|
||||
var identity = expectedRecord.Identity;
|
||||
ManualFinancialSnapshot? freshSnapshot = null;
|
||||
return new DevelopmentDatabaseSmokeScenario(
|
||||
"GraphE/" + identity.Screen,
|
||||
async cancellationToken =>
|
||||
{
|
||||
var search = await service.SearchAsync(
|
||||
identity.Screen,
|
||||
identity.StockName,
|
||||
10,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (search.IsTruncated || search.Items.Any(item =>
|
||||
string.Equals(
|
||||
item.Record.Identity.StockName,
|
||||
identity.StockName,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw Collision("GraphE preflight did not prove absence.");
|
||||
}
|
||||
},
|
||||
cancellationToken => IgnoreResultAsync(
|
||||
service.CreateAsync(expectedRecord, cancellationToken)),
|
||||
async cancellationToken =>
|
||||
{
|
||||
freshSnapshot = await service.GetAsync(identity, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
VerifyManualRecord(expectedRecord, freshSnapshot);
|
||||
},
|
||||
save: null,
|
||||
verifySave: null,
|
||||
async cancellationToken =>
|
||||
{
|
||||
freshSnapshot ??= await service.GetAsync(identity, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await service.DeleteAsync(freshSnapshot, cancellationToken).ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken => EnsureManualAbsentAsync(service, identity, cancellationToken),
|
||||
IsOutcomeUnknown);
|
||||
}
|
||||
|
||||
private static DevelopmentDatabaseSmokeScenario CreateNamedPlaylistScenario(
|
||||
INamedPlaylistPersistenceService service,
|
||||
ExistingChildRowGuard childGuard,
|
||||
string title)
|
||||
{
|
||||
string? programCode = null;
|
||||
var savedItem = new NamedPlaylistStoredItem(
|
||||
0,
|
||||
true,
|
||||
new LegacySceneSelection(
|
||||
"KOSPI",
|
||||
"SMOKE",
|
||||
"PLATE",
|
||||
"CURRENT",
|
||||
string.Empty),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
return new DevelopmentDatabaseSmokeScenario(
|
||||
"AList/PList named playlist",
|
||||
async cancellationToken =>
|
||||
{
|
||||
programCode = await service.SuggestNextProgramCodeAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var list = await service.ListAsync(
|
||||
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (list.IsTruncated || list.Items.Any(item =>
|
||||
string.Equals(item.Title, title, StringComparison.Ordinal)))
|
||||
{
|
||||
throw Collision("Named-playlist title absence was not proven.");
|
||||
}
|
||||
|
||||
await EnsureNamedPlaylistAbsentAsync(
|
||||
service,
|
||||
programCode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await childGuard.EnsureNamedPlaylistChildrenAbsentAsync(
|
||||
programCode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken => service.CreateDefinitionAsync(
|
||||
Required(programCode),
|
||||
title,
|
||||
cancellationToken),
|
||||
async cancellationToken =>
|
||||
{
|
||||
var document = await service.LoadAsync(
|
||||
Required(programCode),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(
|
||||
document.Definition.Title,
|
||||
title,
|
||||
StringComparison.Ordinal) ||
|
||||
document.Items.Count != 0)
|
||||
{
|
||||
throw Verification("Named-playlist create readback did not match.");
|
||||
}
|
||||
},
|
||||
cancellationToken => service.ReplaceItemsAsync(
|
||||
Required(programCode),
|
||||
[savedItem],
|
||||
cancellationToken),
|
||||
async cancellationToken =>
|
||||
{
|
||||
var document = await service.LoadAsync(
|
||||
Required(programCode),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(
|
||||
document.Definition.Title,
|
||||
title,
|
||||
StringComparison.Ordinal) ||
|
||||
document.Items.Count != 1 ||
|
||||
document.Items[0] != savedItem)
|
||||
{
|
||||
throw Verification("Named-playlist save readback did not match.");
|
||||
}
|
||||
},
|
||||
cancellationToken => service.DeleteAsync(
|
||||
Required(programCode),
|
||||
cancellationToken),
|
||||
async cancellationToken =>
|
||||
{
|
||||
await EnsureNamedPlaylistAbsentAsync(
|
||||
service,
|
||||
Required(programCode),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var list = await service.ListAsync(
|
||||
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (list.IsTruncated || list.Items.Any(item =>
|
||||
string.Equals(item.Title, title, StringComparison.Ordinal)))
|
||||
{
|
||||
throw Verification("Named-playlist cleanup absence was not proven.");
|
||||
}
|
||||
},
|
||||
IsOutcomeUnknown);
|
||||
}
|
||||
|
||||
private static DevelopmentDatabaseSmokeScenario CreateThemeScenario(
|
||||
IThemeCatalogPersistenceService persistence,
|
||||
IThemeSelectionService selection,
|
||||
ExistingChildRowGuard childGuard,
|
||||
ThemeMarket market,
|
||||
string title)
|
||||
{
|
||||
string? themeCode = null;
|
||||
ThemeSelectionIdentity? freshIdentity = null;
|
||||
var session = market == ThemeMarket.Krx
|
||||
? ThemeSession.NotApplicable
|
||||
: ThemeSession.PreMarket;
|
||||
|
||||
return new DevelopmentDatabaseSmokeScenario(
|
||||
"ThemeA/" + market,
|
||||
async cancellationToken =>
|
||||
{
|
||||
await EnsureThemeAbsentAsync(selection, title, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
themeCode = await persistence.SuggestNextCodeAsync(market, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await childGuard.EnsureThemeChildrenAbsentAsync(
|
||||
market,
|
||||
themeCode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken => IgnoreResultAsync(
|
||||
persistence.CreateAsync(
|
||||
new ThemeCatalogDefinition(
|
||||
market,
|
||||
Required(themeCode),
|
||||
title,
|
||||
[]),
|
||||
cancellationToken)),
|
||||
async cancellationToken =>
|
||||
{
|
||||
var search = await selection.SearchAsync(
|
||||
title,
|
||||
ThemeSession.PreMarket,
|
||||
LegacyThemeSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (search.IsTruncated)
|
||||
{
|
||||
throw Verification("Theme readback was truncated.");
|
||||
}
|
||||
|
||||
var matches = search.Items.Where(item =>
|
||||
string.Equals(
|
||||
item.Identity.ThemeTitle,
|
||||
title,
|
||||
StringComparison.Ordinal)).ToArray();
|
||||
if (matches.Length != 1 ||
|
||||
matches[0].Identity.Market != market ||
|
||||
!string.Equals(
|
||||
matches[0].Identity.ThemeCode,
|
||||
themeCode,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw Verification("Theme create readback did not match.");
|
||||
}
|
||||
|
||||
freshIdentity = matches[0].Identity with { Session = session };
|
||||
var preview = await selection.PreviewAsync(
|
||||
freshIdentity,
|
||||
LegacyThemeSelectionService.MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (preview.Items.Count != 0 || preview.IsTruncated)
|
||||
{
|
||||
throw Verification("Theme preview readback did not match.");
|
||||
}
|
||||
},
|
||||
save: null,
|
||||
verifySave: null,
|
||||
cancellationToken => IgnoreResultAsync(
|
||||
persistence.DeleteAsync(
|
||||
freshIdentity ?? new ThemeSelectionIdentity(
|
||||
market,
|
||||
session,
|
||||
Required(themeCode),
|
||||
title),
|
||||
cancellationToken)),
|
||||
cancellationToken => EnsureThemeAbsentAsync(selection, title, cancellationToken),
|
||||
IsOutcomeUnknown);
|
||||
}
|
||||
|
||||
private static DevelopmentDatabaseSmokeScenario CreateExpertScenario(
|
||||
IExpertCatalogPersistenceService persistence,
|
||||
IExpertSelectionService selection,
|
||||
ExistingChildRowGuard childGuard,
|
||||
string name)
|
||||
{
|
||||
string? expertCode = null;
|
||||
ExpertSelectionIdentity? freshIdentity = null;
|
||||
return new DevelopmentDatabaseSmokeScenario(
|
||||
"EList expert catalog",
|
||||
async cancellationToken =>
|
||||
{
|
||||
await EnsureExpertAbsentAsync(selection, name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
expertCode = await persistence.SuggestNextCodeAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await childGuard.EnsureExpertChildrenAbsentAsync(
|
||||
expertCode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken => IgnoreResultAsync(
|
||||
persistence.CreateAsync(
|
||||
new ExpertCatalogDefinition(
|
||||
new ExpertSelectionIdentity(Required(expertCode), name),
|
||||
[]),
|
||||
cancellationToken)),
|
||||
async cancellationToken =>
|
||||
{
|
||||
var search = await selection.SearchAsync(
|
||||
name,
|
||||
LegacyExpertSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (search.IsTruncated)
|
||||
{
|
||||
throw Verification("Expert readback was truncated.");
|
||||
}
|
||||
|
||||
var matches = search.Items.Where(item =>
|
||||
string.Equals(
|
||||
item.Identity.ExpertName,
|
||||
name,
|
||||
StringComparison.Ordinal)).ToArray();
|
||||
if (matches.Length != 1 ||
|
||||
!string.Equals(
|
||||
matches[0].Identity.ExpertCode,
|
||||
expertCode,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw Verification("Expert create readback did not match.");
|
||||
}
|
||||
|
||||
freshIdentity = matches[0].Identity;
|
||||
var preview = await selection.PreviewAsync(
|
||||
freshIdentity,
|
||||
LegacyExpertSelectionService.MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (preview.Items.Count != 0 || preview.IsTruncated)
|
||||
{
|
||||
throw Verification("Expert preview readback did not match.");
|
||||
}
|
||||
},
|
||||
save: null,
|
||||
verifySave: null,
|
||||
cancellationToken => IgnoreResultAsync(
|
||||
persistence.DeleteAsync(
|
||||
freshIdentity ?? new ExpertSelectionIdentity(
|
||||
Required(expertCode),
|
||||
name),
|
||||
cancellationToken)),
|
||||
cancellationToken => EnsureExpertAbsentAsync(selection, name, cancellationToken),
|
||||
IsOutcomeUnknown);
|
||||
}
|
||||
|
||||
private static ManualFinancialRecord CreateManualRecord(
|
||||
ManualFinancialScreenKind screen,
|
||||
string stockName)
|
||||
{
|
||||
var identity = new ManualFinancialIdentity(screen, stockName);
|
||||
return screen switch
|
||||
{
|
||||
ManualFinancialScreenKind.RevenueComposition =>
|
||||
new ManualRevenueCompositionRecord(
|
||||
identity,
|
||||
"2026-07",
|
||||
[new("SMOKE", "100", 100), null, null, null, null]),
|
||||
ManualFinancialScreenKind.GrowthMetrics =>
|
||||
new ManualGrowthMetricsRecord(
|
||||
identity,
|
||||
new(1, 2, 3, 4),
|
||||
new(5, 6, 7, 8),
|
||||
new(9, 10, 11, 12),
|
||||
new(13, 14, 15, 16),
|
||||
new("P1", "P2", "P3", "P4")),
|
||||
ManualFinancialScreenKind.Sales =>
|
||||
new ManualSalesRecord(identity, SixQuarters()),
|
||||
ManualFinancialScreenKind.OperatingProfit =>
|
||||
new ManualOperatingProfitRecord(identity, SixQuarters()),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(screen))
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ManualQuarterValue> SixQuarters() =>
|
||||
[
|
||||
new("Q1", 10),
|
||||
new("Q2", -20),
|
||||
new("Q3", 30),
|
||||
new("Q4", 40),
|
||||
new("Q5", 50),
|
||||
new("Q6", 60)
|
||||
];
|
||||
|
||||
private static void VerifyManualRecord(
|
||||
ManualFinancialRecord expected,
|
||||
ManualFinancialSnapshot actual)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actual.RowVersion) ||
|
||||
actual.Record.Identity != expected.Identity)
|
||||
{
|
||||
throw Verification("GraphE identity or row version did not match.");
|
||||
}
|
||||
|
||||
var matches = (expected, actual.Record) switch
|
||||
{
|
||||
(ManualRevenueCompositionRecord left, ManualRevenueCompositionRecord right) =>
|
||||
string.Equals(left.BaseDate, right.BaseDate, StringComparison.Ordinal) &&
|
||||
left.Slices.SequenceEqual(right.Slices),
|
||||
(ManualGrowthMetricsRecord left, ManualGrowthMetricsRecord right) =>
|
||||
left.SalesGrowth == right.SalesGrowth &&
|
||||
left.OperatingProfitGrowth == right.OperatingProfitGrowth &&
|
||||
left.NetAssetGrowth == right.NetAssetGrowth &&
|
||||
left.NetIncomeGrowth == right.NetIncomeGrowth &&
|
||||
left.Periods == right.Periods,
|
||||
(ManualSalesRecord left, ManualSalesRecord right) =>
|
||||
left.Quarters.SequenceEqual(right.Quarters),
|
||||
(ManualOperatingProfitRecord left, ManualOperatingProfitRecord right) =>
|
||||
left.Quarters.SequenceEqual(right.Quarters),
|
||||
_ => false
|
||||
};
|
||||
if (!matches)
|
||||
{
|
||||
throw Verification("GraphE create readback did not match.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureManualAbsentAsync(
|
||||
IManualFinancialScreenService service,
|
||||
ManualFinancialIdentity identity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = await service.GetAsync(identity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ManualFinancialNotFoundException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw Verification("GraphE record still exists.");
|
||||
}
|
||||
|
||||
private static async Task EnsureNamedPlaylistAbsentAsync(
|
||||
INamedPlaylistPersistenceService service,
|
||||
string programCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = await service.LoadAsync(programCode, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NamedPlaylistNotFoundException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw Verification("Named playlist still exists.");
|
||||
}
|
||||
|
||||
private static async Task EnsureThemeAbsentAsync(
|
||||
IThemeSelectionService selection,
|
||||
string title,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var search = await selection.SearchAsync(
|
||||
title,
|
||||
ThemeSession.PreMarket,
|
||||
LegacyThemeSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (search.IsTruncated || search.Items.Any(item =>
|
||||
string.Equals(
|
||||
item.Identity.ThemeTitle,
|
||||
title,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw Collision("Theme absence was not proven.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureExpertAbsentAsync(
|
||||
IExpertSelectionService selection,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var search = await selection.SearchAsync(
|
||||
name,
|
||||
LegacyExpertSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (search.IsTruncated || search.Items.Any(item =>
|
||||
string.Equals(
|
||||
item.Identity.ExpertName,
|
||||
name,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw Collision("Expert absence was not proven.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task IgnoreResultAsync<T>(Task<T> operation)
|
||||
{
|
||||
_ = await operation.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string Required(string? value) =>
|
||||
value ?? throw new InvalidOperationException("Smoke preflight did not produce an identity.");
|
||||
|
||||
private static bool IsOutcomeUnknown(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
if (exception is ManualFinancialMutationException manual && manual.OutcomeUnknown ||
|
||||
exception is NamedPlaylistMutationException named && named.OutcomeUnknown ||
|
||||
exception is OperatorCatalogMutationException catalog && catalog.OutcomeUnknown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (exception is AggregateException aggregate &&
|
||||
aggregate.InnerExceptions.Any(IsOutcomeUnknown))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return exception.InnerException is not null && IsOutcomeUnknown(exception.InnerException);
|
||||
}
|
||||
|
||||
private static InvalidOperationException Collision(string message) => new(message);
|
||||
|
||||
private static InvalidOperationException Verification(string message) => new(message);
|
||||
}
|
||||
21
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Program.cs
Normal file
21
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (OwnedManualSmokeResidueRecovery.IsRequested(args))
|
||||
{
|
||||
return await OwnedManualSmokeResidueRecovery.RunAsync(args, Console.Out)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await DevelopmentDatabaseWriteSmokeApp.RunAsync(
|
||||
args,
|
||||
ProductionDevelopmentDatabaseSmokePlan.Create,
|
||||
Console.Out)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests")]
|
||||
49
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/README.md
Normal file
49
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Development database write smoke
|
||||
|
||||
This executable is deliberately separate from the read-only `DbSmoke` tool. It
|
||||
does not load database configuration or make a database call unless the exact
|
||||
write acknowledgement is present:
|
||||
|
||||
```powershell
|
||||
dotnet run --project tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj -- `
|
||||
--i-understand-this-writes-development-database
|
||||
```
|
||||
|
||||
Use `--configuration <path>` only when the normal LocalAppData configuration is
|
||||
not the intended development configuration. Credentials, SQL, generated
|
||||
identities, and database values are never printed.
|
||||
|
||||
The plan covers every migrated database writer:
|
||||
|
||||
- all four Oracle GraphE `INPUT_*` screens;
|
||||
- Oracle AList/PList definition creation and item replacement;
|
||||
- ThemeA KRX (Oracle) and NXT (MariaDB) creation;
|
||||
- EList expert creation (Oracle).
|
||||
|
||||
Each scenario uses a legacy-column-safe 20-character
|
||||
`MBW<UTC-date><40-bit nonce><scenario>` name,
|
||||
proves that name is absent, performs each production-service mutation once,
|
||||
reads the row back through the production read service, deletes it in `finally`,
|
||||
and verifies absence. Numeric legacy keys cannot contain the prefix, so their
|
||||
production next-code service supplies the numeric key and the conditional insert
|
||||
still rejects a concurrent collision without overwriting an existing row. A
|
||||
bound, read-only child-row preflight also rejects keys that have legacy orphaned
|
||||
`PLAY_LIST`, `SB_ITEM`, or `RECOMMEND_LIST` rows, so cleanup cannot delete an
|
||||
older row.
|
||||
|
||||
Both read and write retries are disabled. If a mutation outcome is unknown, the
|
||||
tool stops immediately and issues no retry, cleanup mutation, or later scenario.
|
||||
Its redacted output then reports that the uniquely prefixed row may require
|
||||
manual inspection.
|
||||
|
||||
If a known-committed manual smoke row could not be parsed or deleted, reconcile
|
||||
only the tool-owned current-UTC-date namespace with:
|
||||
|
||||
```powershell
|
||||
dotnet run --project tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj -- `
|
||||
--reconcile-known-owned-manual-residue `
|
||||
--i-understand-this-writes-development-database
|
||||
```
|
||||
|
||||
The recovery refuses a truncated or non-singular per-table match and never
|
||||
retries a delete.
|
||||
@@ -0,0 +1,64 @@
|
||||
#nullable enable
|
||||
|
||||
using MMoneyCoderSharp.Data;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MySqlConnector;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
/// <summary>
|
||||
/// Emits only a provider/type classification. Messages, SQL, credentials,
|
||||
/// identifiers, and values deliberately never cross this boundary.
|
||||
/// </summary>
|
||||
internal static class RedactedDatabaseFailureClassifier
|
||||
{
|
||||
internal static string Classify(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
foreach (var current in Flatten(exception))
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
case OracleException oracle:
|
||||
return "Oracle-" + oracle.Number.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
case MySqlException mariaDb:
|
||||
return "MariaDb-" + ((int)mariaDb.ErrorCode).ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
case DatabaseOperationException database:
|
||||
return database.DataSource + "-" +
|
||||
(database.ProviderErrorCode?.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture) ?? "none");
|
||||
case ManualFinancialMutationRejectedException rejected:
|
||||
return "ManualRejected-" + rejected.Reason;
|
||||
case OperatorCatalogConflictException conflict:
|
||||
return "CatalogConflict-" + conflict.Kind + "-" + conflict.CommandKind;
|
||||
}
|
||||
}
|
||||
|
||||
return exception.GetType().Name;
|
||||
}
|
||||
|
||||
private static IEnumerable<Exception> Flatten(Exception exception)
|
||||
{
|
||||
var pending = new Stack<Exception>();
|
||||
var visited = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
|
||||
pending.Push(exception);
|
||||
while (pending.TryPop(out var current) && visited.Add(current))
|
||||
{
|
||||
yield return current;
|
||||
if (current is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions.Reverse())
|
||||
{
|
||||
pending.Push(inner);
|
||||
}
|
||||
}
|
||||
else if (current.InnerException is not null)
|
||||
{
|
||||
pending.Push(current.InnerException);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeCommandLine.cs
Normal file
65
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeCommandLine.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal sealed record SmokeCommandOptions(
|
||||
bool ShowHelp,
|
||||
bool IsWriteAcknowledged,
|
||||
string? ConfigurationPath);
|
||||
|
||||
internal static class SmokeCommandLine
|
||||
{
|
||||
internal const string WriteAcknowledgement =
|
||||
"--i-understand-this-writes-development-database";
|
||||
internal const string ConfigurationOption = "--configuration";
|
||||
|
||||
internal static bool TryParse(
|
||||
IReadOnlyList<string> arguments,
|
||||
out SmokeCommandOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
options = new SmokeCommandOptions(false, false, null);
|
||||
if (arguments.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arguments.Count == 1 &&
|
||||
arguments[0] is "--help" or "-h" or "/?")
|
||||
{
|
||||
options = options with { ShowHelp = true };
|
||||
return true;
|
||||
}
|
||||
|
||||
var acknowledged = false;
|
||||
string? configurationPath = null;
|
||||
for (var index = 0; index < arguments.Count; index++)
|
||||
{
|
||||
var argument = arguments[index];
|
||||
switch (argument)
|
||||
{
|
||||
case WriteAcknowledgement when !acknowledged:
|
||||
acknowledged = true;
|
||||
break;
|
||||
case ConfigurationOption when configurationPath is null &&
|
||||
index + 1 < arguments.Count:
|
||||
configurationPath = arguments[++index];
|
||||
if (string.IsNullOrWhiteSpace(configurationPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
options = new SmokeCommandOptions(false, acknowledged, configurationPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static string Usage =>
|
||||
"Usage: MBN_STOCK_WEBVIEW.DbWriteSmoke " + WriteAcknowledgement +
|
||||
" [" + ConfigurationOption + " <database.local.json>]";
|
||||
}
|
||||
55
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeRunIdentity.cs
Normal file
55
tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeRunIdentity.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
|
||||
|
||||
internal sealed record SmokeRunIdentity(string BaseName)
|
||||
{
|
||||
internal const string Prefix = "MBW";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, char> ScenarioCodes =
|
||||
new Dictionary<string, char>(StringComparer.Ordinal)
|
||||
{
|
||||
["G_PIE"] = 'P',
|
||||
["G_GROW"] = 'G',
|
||||
["G_SELL"] = 'S',
|
||||
["G_PROFIT"] = 'O',
|
||||
["PLAYLIST"] = 'L',
|
||||
["THEME_KRX"] = 'K',
|
||||
["THEME_NXT"] = 'N',
|
||||
["EXPERT"] = 'E'
|
||||
};
|
||||
|
||||
internal static SmokeRunIdentity Create(DateTimeOffset utcNow, Guid nonce)
|
||||
{
|
||||
if (nonce == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("A non-empty smoke nonce is required.", nameof(nonce));
|
||||
}
|
||||
|
||||
var utc = utcNow.ToUniversalTime();
|
||||
var timestamp = utc.ToString("yyMMdd", CultureInfo.InvariantCulture);
|
||||
var suffix = nonce.ToString("N", CultureInfo.InvariantCulture)[..10]
|
||||
.ToUpperInvariant();
|
||||
return new SmokeRunIdentity(Prefix + timestamp + suffix);
|
||||
}
|
||||
|
||||
internal string For(string suffix)
|
||||
{
|
||||
if (!ScenarioCodes.TryGetValue(suffix, out var scenarioCode))
|
||||
{
|
||||
throw new ArgumentException("The smoke identity suffix is invalid.", nameof(suffix));
|
||||
}
|
||||
|
||||
var value = BaseName + scenarioCode;
|
||||
if (value.Length != 20 ||
|
||||
value.Any(static character =>
|
||||
!(character is >= 'A' and <= 'Z' or >= '0' and <= '9')))
|
||||
{
|
||||
throw new InvalidOperationException("The smoke identity is outside its closed boundary.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user