411 lines
19 KiB
C#
411 lines
19 KiB
C#
using System.Data;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
|
using MMoneyCoderSharp.Data;
|
|
|
|
internal static class NxtThemeRestoreDbAudit
|
|
{
|
|
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
|
|
|
|
private const string ActiveRowsSql = """
|
|
SELECT LIST_TEXT
|
|
FROM (
|
|
SELECT p.LIST_TEXT
|
|
FROM DC_LIST d
|
|
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
|
|
WHERE p.LIST_TEXT LIKE '1^테마_NXT^%'
|
|
ORDER BY d.DC_CODE, TO_NUMBER(p.ITEM_INDEX)
|
|
)
|
|
WHERE ROWNUM <= :row_limit
|
|
""";
|
|
|
|
internal static async Task RunAsync(
|
|
IDataQueryExecutor executor,
|
|
MariaDbDatabaseOptions currentOptions,
|
|
MariaDbEndpointReference referenceOptions,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await RunEndpointAuditAsync(
|
|
executor,
|
|
currentOptions,
|
|
referenceOptions,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
const int maximumRows = 1_000;
|
|
var spec = new DataQuerySpec(
|
|
ActiveRowsSql,
|
|
[new DataQueryParameter("row_limit", maximumRows + 1, DbType.Int32)]);
|
|
spec.ValidateFor(DataSourceKind.Oracle);
|
|
var table = await executor.ExecuteAsync(
|
|
DataSourceKind.Oracle,
|
|
ActiveRowsQueryName,
|
|
spec,
|
|
cancellationToken);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (table.Columns.Count != 1 ||
|
|
table.Columns[0].ColumnName != "LIST_TEXT" ||
|
|
table.Columns[0].DataType != typeof(string) ||
|
|
table.Rows.Count > maximumRows)
|
|
{
|
|
throw new InvalidDataException("The active NXT-theme audit boundary is invalid.");
|
|
}
|
|
|
|
var rows = new List<LegacyNamedNxtThemeRestoreRow>(table.Rows.Count);
|
|
var grammar = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
foreach (DataRow dataRow in table.Rows)
|
|
{
|
|
if (dataRow[0] is not string text)
|
|
{
|
|
throw new InvalidDataException("An active NXT-theme row is not text.");
|
|
}
|
|
|
|
var fields = text.Split('^');
|
|
if (fields.Length != 7 || fields[0] != "1" || fields[1] != "테마_NXT")
|
|
{
|
|
throw new InvalidDataException("An active NXT-theme row has an invalid shape.");
|
|
}
|
|
|
|
var pageText = fields[5].Replace(" ", string.Empty, StringComparison.Ordinal);
|
|
rows.Add(new LegacyNamedNxtThemeRestoreRow(
|
|
rows.Count,
|
|
IsEnabled: true,
|
|
fields[1],
|
|
fields[2],
|
|
fields[3],
|
|
fields[4],
|
|
pageText,
|
|
fields[6]));
|
|
var key = string.Join('|', fields[3], fields[4]);
|
|
grammar[key] = grammar.GetValueOrDefault(key) + 1;
|
|
}
|
|
|
|
if (rows.Count == 0)
|
|
{
|
|
Console.WriteLine("NXT_THEME_RESTORE_AUDIT: active=0");
|
|
return;
|
|
}
|
|
|
|
var result = await new LegacyNamedNxtThemeRestoreService(executor)
|
|
.ResolveAsync(rows, cancellationToken);
|
|
var resolved = result.Rows.Count(row => row.IsResolved);
|
|
var identityMapped = result.Rows.Count(row => row.ObservedCurrentThemeCode is not null);
|
|
var remapped = result.Rows
|
|
.Select((row, index) =>
|
|
row.ObservedCurrentThemeCode is not null &&
|
|
!string.Equals(
|
|
row.ObservedCurrentThemeCode,
|
|
rows[index].DataCode,
|
|
StringComparison.Ordinal))
|
|
.Count(static value => value);
|
|
var failures = result.Rows
|
|
.Where(row => row.Failure.HasValue)
|
|
.GroupBy(row => row.Failure!.Value)
|
|
.OrderBy(group => group.Key)
|
|
.Select(group => $"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}")
|
|
.ToArray();
|
|
var actions = result.Rows
|
|
.Where(row => row.IsResolved)
|
|
.GroupBy(row => row.Identity!.ActionId)
|
|
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
|
.Select(group => $"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}")
|
|
.ToArray();
|
|
var liveCounts = result.Rows
|
|
.Where(row => row.IsResolved)
|
|
.Select(row => row.Identity!.LiveItemCount)
|
|
.ToArray();
|
|
|
|
Console.WriteLine(
|
|
"NXT_THEME_RESTORE_AUDIT: " +
|
|
$"active={rows.Count.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"distinctTitles={rows.Select(row => row.Subject).Distinct(StringComparer.Ordinal).Count().ToString(CultureInfo.InvariantCulture)} " +
|
|
$"grammarVariants={grammar.Count.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"identityMapped={identityMapped.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"resolved={resolved.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"remapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"blocked={(rows.Count - resolved).ToString(CultureInfo.InvariantCulture)} " +
|
|
$"session={LegacyNamedNxtThemeRestoreService.SessionForLocalHour(DateTime.Now.Hour)}");
|
|
Console.WriteLine(
|
|
"NXT_THEME_RESTORE_GRAMMAR: " +
|
|
$"variants={grammar.Count.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"rows={grammar.Values.Sum().ToString(CultureInfo.InvariantCulture)} " +
|
|
"rawValuesPrinted=false");
|
|
Console.WriteLine("NXT_THEME_RESTORE_ACTIONS: " +
|
|
(actions.Length == 0 ? "none" : string.Join(",", actions)));
|
|
Console.WriteLine("NXT_THEME_RESTORE_FAILURES: " +
|
|
(failures.Length == 0 ? "none" : string.Join(",", failures)));
|
|
Console.WriteLine("NXT_THEME_RESTORE_LIVE_ITEMS: " +
|
|
(liveCounts.Length == 0
|
|
? "none"
|
|
: $"min={liveCounts.Min().ToString(CultureInfo.InvariantCulture)}," +
|
|
$"max={liveCounts.Max().ToString(CultureInfo.InvariantCulture)}," +
|
|
$"sum={liveCounts.Sum().ToString(CultureInfo.InvariantCulture)}"));
|
|
|
|
await RunJoinDiagnosticAsync(executor, rows, result, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task RunJoinDiagnosticAsync(
|
|
IDataQueryExecutor executor,
|
|
IReadOnlyList<LegacyNamedNxtThemeRestoreRow> sourceRows,
|
|
LegacyNamedNxtThemeRestoreResult restore,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var codes = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
for (var index = 0; index < restore.Rows.Count; index++)
|
|
{
|
|
var current = restore.Rows[index].ObservedCurrentThemeCode;
|
|
if (current is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var stored = sourceRows[index].DataCode;
|
|
if (codes.TryGetValue(current, out var previous) &&
|
|
!string.Equals(previous, stored, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidDataException(
|
|
"The NXT join audit found conflicting stored codes for one current identity.");
|
|
}
|
|
|
|
codes[current] = stored;
|
|
}
|
|
|
|
if (codes.Count == 0)
|
|
{
|
|
Console.WriteLine("NXT_THEME_JOIN_DIAGNOSTIC: currentThemes=0");
|
|
return;
|
|
}
|
|
|
|
// Sort only to make repeated aggregate runs deterministic. Neither key
|
|
// is printed or included in an exception.
|
|
var identities = codes
|
|
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
|
.Select(static pair => new LegacyNxtThemeJoinAuditIdentity(pair.Key, pair.Value))
|
|
.ToArray();
|
|
var audit = await new LegacyNxtThemeJoinAuditService(executor)
|
|
.AuditAsync(identities, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var evidence = audit.Themes;
|
|
var rawCurrent = evidence.Sum(static row => row.RawCurrentItemCount);
|
|
var rawStored = evidence.Sum(static row => row.RawStoredItemCount);
|
|
var currentOriginal = evidence.Sum(static row => row.CurrentOriginalJoinCount);
|
|
var currentNonStopped = evidence.Sum(static row => row.CurrentNonStoppedJoinCount);
|
|
var currentLive = evidence.Sum(static row => row.CurrentLiveJoinCount);
|
|
var storedOriginal = evidence.Sum(static row => row.StoredOriginalJoinCount);
|
|
var storedNonStopped = evidence.Sum(static row => row.StoredNonStoppedJoinCount);
|
|
var storedLive = evidence.Sum(static row => row.StoredLiveJoinCount);
|
|
var exactCandidate = evidence.Sum(static row => row.CurrentExactJoinCount);
|
|
var stripBothCandidate = evidence.Sum(static row => row.CurrentStripBothJoinCount);
|
|
var rightSixCandidate = evidence.Sum(static row => row.CurrentRightSixJoinCount);
|
|
var validCodes = evidence.Sum(static row => row.CurrentValidItemCodeCount);
|
|
var kospiMaster = evidence.Sum(static row => row.CurrentKospiMasterJoinCount);
|
|
var kosdaqMaster = evidence.Sum(static row => row.CurrentKosdaqMasterJoinCount);
|
|
var kospiOnline = evidence.Sum(static row => row.CurrentKospiOnlineJoinCount);
|
|
var kosdaqOnline = evidence.Sum(static row => row.CurrentKosdaqOnlineJoinCount);
|
|
var rawCurrentZero = evidence.Count(static row => row.RawCurrentItemCount == 0);
|
|
var rawStoredPositive = evidence.Count(static row => row.RawStoredItemCount > 0);
|
|
var originalCurrentPositive = evidence.Count(static row => row.CurrentOriginalJoinCount > 0);
|
|
|
|
Console.WriteLine(
|
|
"NXT_THEME_JOIN_DIAGNOSTIC: " +
|
|
$"currentThemes={evidence.Count.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"remappedThemes={identities.Count(identity => !string.Equals(identity.CurrentThemeCode, identity.StoredThemeCode, StringComparison.Ordinal)).ToString(CultureInfo.InvariantCulture)} " +
|
|
$"rawCurrent={rawCurrent.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"rawStored={rawStored.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"currentOriginalJoin={currentOriginal.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"currentNonStopped={currentNonStopped.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"currentLive={currentLive.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"storedOriginalJoin={storedOriginal.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"storedNonStopped={storedNonStopped.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"storedLive={storedLive.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"currentValidItemCodes={validCodes.ToString(CultureInfo.InvariantCulture)}");
|
|
Console.WriteLine(
|
|
"NXT_THEME_MASTER_JOIN_DIAGNOSTIC: " +
|
|
$"kospiMaster={kospiMaster.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"kosdaqMaster={kosdaqMaster.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"kospiOnline={kospiOnline.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"kosdaqOnline={kosdaqOnline.ToString(CultureInfo.InvariantCulture)}");
|
|
Console.WriteLine(
|
|
"NXT_THEME_JOIN_CANDIDATES_DIAGNOSTIC_ONLY: " +
|
|
$"exact={exactCandidate.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"stripBoth={stripBothCandidate.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"rightSix={rightSixCandidate.ToString(CultureInfo.InvariantCulture)}");
|
|
Console.WriteLine(
|
|
"NXT_THEME_JOIN_CLASSIFICATION: " +
|
|
$"currentRawZeroThemes={rawCurrentZero.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"storedRawPositiveThemes={rawStoredPositive.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"currentOriginalPositiveThemes={originalCurrentPositive.ToString(CultureInfo.InvariantCulture)} " +
|
|
$"root={ClassifyRoot(evidence)} " +
|
|
"action=read-only-review-no-fallback");
|
|
}
|
|
|
|
private static string ClassifyRoot(
|
|
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> evidence)
|
|
{
|
|
if (evidence.All(static row => row.RawCurrentItemCount == 0) &&
|
|
evidence.All(static row => row.RawStoredItemCount > 0))
|
|
{
|
|
return "CurrentThemeCodeHasNoSbItemRowsStoredCodeHasRows";
|
|
}
|
|
|
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
|
|
evidence.All(static row =>
|
|
row.CurrentKospiMasterJoinCount == 0 &&
|
|
row.CurrentKosdaqMasterJoinCount == 0))
|
|
{
|
|
return "CurrentItemsMissingFromNxtStockMasters";
|
|
}
|
|
|
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
|
|
evidence.Any(static row =>
|
|
row.CurrentKospiMasterJoinCount > 0 ||
|
|
row.CurrentKosdaqMasterJoinCount > 0))
|
|
{
|
|
return "VAllStockExcludesCurrentNxtMasterRows";
|
|
}
|
|
|
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
|
|
evidence.Any(static row =>
|
|
row.CurrentExactJoinCount > 0 ||
|
|
row.CurrentStripBothJoinCount > 0 ||
|
|
row.CurrentRightSixJoinCount > 0))
|
|
{
|
|
return "ItemCodeJoinShapeMismatch";
|
|
}
|
|
|
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0))
|
|
{
|
|
return "CurrentItemsHaveNoStockIdentityJoin";
|
|
}
|
|
|
|
return "MixedOrUnclassifiedReadOnlyEvidence";
|
|
}
|
|
|
|
private static async Task RunEndpointAuditAsync(
|
|
IDataQueryExecutor executor,
|
|
MariaDbDatabaseOptions currentOptions,
|
|
MariaDbEndpointReference referenceOptions,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(executor);
|
|
ArgumentNullException.ThrowIfNull(currentOptions);
|
|
ArgumentNullException.ThrowIfNull(referenceOptions);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var original = NormalizeEndpoint(
|
|
referenceOptions.Host,
|
|
referenceOptions.Port,
|
|
referenceOptions.Database);
|
|
var current = NormalizeEndpoint(
|
|
currentOptions.Host,
|
|
currentOptions.Port,
|
|
currentOptions.Database);
|
|
var spec = new DataQuerySpec(
|
|
"SELECT DATABASE() CURRENT_DATABASE, VERSION() SERVER_VERSION");
|
|
spec.ValidateFor(DataSourceKind.MariaDb);
|
|
var table = await executor.ExecuteAsync(
|
|
DataSourceKind.MariaDb,
|
|
"AUDIT_NXT_ENDPOINT_METADATA",
|
|
spec,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (table is null || table.Columns.Count != 2 || table.Rows.Count != 1 ||
|
|
!HasStringColumn(table.Columns[0], "CURRENT_DATABASE") ||
|
|
!HasStringColumn(table.Columns[1], "SERVER_VERSION") ||
|
|
table.Rows[0][0] is not string actualDatabase ||
|
|
table.Rows[0][1] is not string serverVersion ||
|
|
string.IsNullOrWhiteSpace(actualDatabase) ||
|
|
string.IsNullOrWhiteSpace(serverVersion))
|
|
{
|
|
throw new InvalidDataException(
|
|
"The NXT endpoint metadata audit returned an invalid closed result.");
|
|
}
|
|
|
|
var originalHash = HashEndpoint(original);
|
|
var currentHash = HashEndpoint(current);
|
|
var configuredEqual = string.Equals(original, current, StringComparison.Ordinal);
|
|
var actualDatabaseMatches = string.Equals(
|
|
NormalizeDatabase(actualDatabase),
|
|
NormalizeDatabase(currentOptions.Database),
|
|
StringComparison.Ordinal);
|
|
var serverFamily = serverVersion.Contains(
|
|
"MariaDB",
|
|
StringComparison.OrdinalIgnoreCase)
|
|
? "MariaDB"
|
|
: "MySqlCompatible";
|
|
var versionParts = serverVersion.Split('.', '-', '+');
|
|
var versionMajorMinor = versionParts.Length >= 2 &&
|
|
int.TryParse(versionParts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) &&
|
|
int.TryParse(versionParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
|
|
? $"{major.ToString(CultureInfo.InvariantCulture)}.{minor.ToString(CultureInfo.InvariantCulture)}"
|
|
: "unclassified";
|
|
|
|
Console.WriteLine(
|
|
"NXT_ENDPOINT_AUDIT: " +
|
|
$"originalSha256={originalHash} " +
|
|
$"currentSha256={currentHash} " +
|
|
$"configuredEqual={configuredEqual.ToString().ToLowerInvariant()} " +
|
|
$"actualDatabaseMatchesCurrent={actualDatabaseMatches.ToString().ToLowerInvariant()} " +
|
|
$"serverFamily={serverFamily} " +
|
|
$"serverMajorMinor={versionMajorMinor}");
|
|
}
|
|
|
|
private static string NormalizeEndpoint(string? host, int port, string? database)
|
|
{
|
|
var normalizedHost = (host ?? string.Empty)
|
|
.Trim()
|
|
.TrimEnd('.')
|
|
.ToLowerInvariant();
|
|
var normalizedDatabase = NormalizeDatabase(database);
|
|
if (normalizedHost.Length == 0 || normalizedHost.Length > 253 ||
|
|
normalizedDatabase.Length == 0 || normalizedDatabase.Length > 128 ||
|
|
port is < 1 or > 65_535 ||
|
|
normalizedHost.Any(static value => char.IsControl(value)) ||
|
|
normalizedDatabase.Any(static value => char.IsControl(value)))
|
|
{
|
|
throw new InvalidDataException(
|
|
"A MariaDB endpoint is invalid at the closed audit boundary.");
|
|
}
|
|
|
|
return string.Join(
|
|
'\u001f',
|
|
normalizedHost,
|
|
port.ToString(CultureInfo.InvariantCulture),
|
|
normalizedDatabase);
|
|
}
|
|
|
|
private static string NormalizeDatabase(string? database) =>
|
|
(database ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
|
|
|
|
private static string HashEndpoint(string endpoint) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint)));
|
|
|
|
private static bool HasStringColumn(DataColumn column, string name) =>
|
|
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
|
column.DataType == typeof(string);
|
|
}
|
|
|
|
internal sealed record MariaDbEndpointReference(
|
|
string? Host,
|
|
int Port,
|
|
string? Database)
|
|
{
|
|
internal static MariaDbEndpointReference From(MariaDbDatabaseOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
return new MariaDbEndpointReference(options.Host, options.Port, options.Database);
|
|
}
|
|
}
|