feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -1,9 +1,14 @@
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 OriginalConfigurationPath =
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini";
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
private const string ActiveRowsSql = """
@@ -20,8 +25,12 @@ internal static class NxtThemeRestoreDbAudit
internal static async Task RunAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
CancellationToken cancellationToken)
{
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken)
.ConfigureAwait(false);
const int maximumRows = 1_000;
var spec = new DataQuerySpec(
ActiveRowsSql,
@@ -116,9 +125,11 @@ internal static class NxtThemeRestoreDbAudit
$"remapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
$"blocked={(rows.Count - resolved).ToString(CultureInfo.InvariantCulture)} " +
$"session={LegacyNamedNxtThemeRestoreService.SessionForLocalHour(DateTime.Now.Hour)}");
Console.WriteLine("NXT_THEME_RESTORE_GRAMMAR: " + string.Join(",", grammar
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}")));
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: " +
@@ -129,5 +140,323 @@ internal static class NxtThemeRestoreDbAudit
: $"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,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
ArgumentNullException.ThrowIfNull(currentOptions);
cancellationToken.ThrowIfCancellationRequested();
var original = ReadOriginalEndpoint(OriginalConfigurationPath);
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 ReadOriginalEndpoint(string path)
{
if (!File.Exists(path))
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration is unavailable.");
}
var source = new FileInfo(path);
if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
source.Length is < 1 or > 64 * 1024)
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration failed its file boundary.");
}
var inMariaSection = false;
string? connectionName = null;
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (line.StartsWith("[", StringComparison.Ordinal) &&
line.EndsWith("]", StringComparison.Ordinal))
{
inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal);
continue;
}
if (!inMariaSection || line.Length == 0 ||
line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
const string prefix = "ConnectionName=";
if (line.StartsWith(prefix, StringComparison.Ordinal))
{
if (connectionName is not null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is ambiguous.");
}
connectionName = line[prefix.Length..];
}
}
if (connectionName is null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is unavailable.");
}
var slash = connectionName.LastIndexOf('/');
var colon = connectionName.LastIndexOf(':', slash - 1);
if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 ||
!int.TryParse(
connectionName.AsSpan(colon + 1, slash - colon - 1),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var port))
{
throw new InvalidDataException(
"The original MariaDB endpoint has an invalid closed shape.");
}
return NormalizeEndpoint(
connectionName[..colon],
port,
connectionName[(slash + 1)..]);
}
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);
}