feat: restore legacy named playlist workflows
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
public sealed record NamedManualRestoreReadOnlyAuditSummary(
|
||||
int CandidateCount,
|
||||
int FreshMaterializedCount,
|
||||
int BlockedCount,
|
||||
LegacyManualOperatorDataRuntimeState RuntimeState,
|
||||
bool OperatorFilesStable,
|
||||
IReadOnlyDictionary<LegacyNamedManualRestoreKind, int> KindCounts,
|
||||
IReadOnlyDictionary<string, int> BlockReasons);
|
||||
|
||||
/// <summary>
|
||||
/// Production-data audit for the manual subset of PLAY_LIST. Every database
|
||||
/// command is a SELECT through an IDataQueryExecutor. Operator files are opened
|
||||
/// only through their production read paths; the before/after snapshot proves
|
||||
/// that no file, marker, or intent entry was created or changed.
|
||||
/// </summary>
|
||||
public static class NamedManualRestoreReadOnlyAudit
|
||||
{
|
||||
private const string QueryName = "AUDIT_NAMED_MANUAL_RESTORE_ROWS";
|
||||
private const int MaximumRows = 20_000;
|
||||
private const int BrowserStockSearchLimit = 200;
|
||||
|
||||
private const string Sql = """
|
||||
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
|
||||
FROM (
|
||||
SELECT TRIM(d.DC_CODE) DC_CODE,
|
||||
d.DC_TITLE DC_TITLE,
|
||||
p.LIST_TEXT LIST_TEXT,
|
||||
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
|
||||
FROM DC_LIST d
|
||||
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
|
||||
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
|
||||
)
|
||||
WHERE ROWNUM <= :row_limit
|
||||
""";
|
||||
|
||||
public static async Task<NamedManualRestoreReadOnlyAuditSummary> RunAsync(
|
||||
IDataQueryExecutor executor,
|
||||
string trustedDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
if (string.IsNullOrWhiteSpace(trustedDirectory) ||
|
||||
!Path.IsPathFullyQualified(trustedDirectory))
|
||||
{
|
||||
throw InvalidBoundary("trusted directory composition");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var candidates = await LoadCandidatesAsync(executor, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var runtime = LegacyManualOperatorDataRuntimeGuard.Inspect(trustedDirectory);
|
||||
if (!runtime.IsReady)
|
||||
{
|
||||
throw InvalidBoundary("trusted runtime state");
|
||||
}
|
||||
|
||||
var before = SnapshotDirectory(trustedDirectory);
|
||||
var results = new List<RestoreResult>(candidates.Count);
|
||||
var financialService = new LegacyManualFinancialScreenService(executor);
|
||||
var stockSearchService = new LegacyStockSearchService(executor);
|
||||
var netSellSource = new S5025TrustedManualFileDataSource(trustedDirectory);
|
||||
TrustedSourceEvidence trustedSources;
|
||||
using (var viSource = new ViTrustedManualFileStore(trustedDirectory))
|
||||
{
|
||||
trustedSources = await ReadTrustedSourcesAsync(
|
||||
netSellSource,
|
||||
viSource,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
results.Add(candidate.Kind switch
|
||||
{
|
||||
LegacyNamedManualRestoreKind.Financial =>
|
||||
await ResolveFinancialAsync(
|
||||
candidate,
|
||||
financialService,
|
||||
stockSearchService,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
LegacyNamedManualRestoreKind.NetSell =>
|
||||
await ResolveNetSellAsync(candidate, netSellSource, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
LegacyNamedManualRestoreKind.Vi =>
|
||||
await ResolveViAsync(candidate, viSource, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
_ => throw InvalidBoundary("manual candidate kind")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var after = SnapshotDirectory(trustedDirectory);
|
||||
var filesStable = before.Matches(after);
|
||||
if (!filesStable)
|
||||
{
|
||||
throw InvalidBoundary("operator file immutability");
|
||||
}
|
||||
|
||||
var kindCounts = candidates
|
||||
.GroupBy(static candidate => candidate.Kind)
|
||||
.ToDictionary(static group => group.Key, static group => group.Count());
|
||||
var screenCounts = candidates
|
||||
.Where(static candidate =>
|
||||
candidate.Kind == LegacyNamedManualRestoreKind.Financial)
|
||||
.GroupBy(static candidate => candidate.FinancialScreen!.Value)
|
||||
.OrderBy(static group => group.Key)
|
||||
.Select(group =>
|
||||
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
var audienceCounts = candidates
|
||||
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.NetSell)
|
||||
.GroupBy(static candidate => candidate.Audience!.Value)
|
||||
.OrderBy(static group => group.Key)
|
||||
.Select(group =>
|
||||
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
var viRows = candidates
|
||||
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.Vi)
|
||||
.ToArray();
|
||||
var financialOutcomes = candidates
|
||||
.Select((candidate, index) => (Candidate: candidate, Result: results[index]))
|
||||
.Where(static pair =>
|
||||
pair.Candidate.Kind == LegacyNamedManualRestoreKind.Financial)
|
||||
.GroupBy(static pair => pair.Candidate.FinancialScreen!.Value)
|
||||
.OrderBy(static group => group.Key)
|
||||
.Select(group =>
|
||||
{
|
||||
var freshCount = group.Count(static pair => pair.Result.IsFresh);
|
||||
var blocked = group
|
||||
.Where(static pair => !pair.Result.IsFresh)
|
||||
.GroupBy(static pair => pair.Result.Reason, StringComparer.Ordinal)
|
||||
.OrderBy(static reason => reason.Key, StringComparer.Ordinal)
|
||||
.Select(reason =>
|
||||
$"{reason.Key}:{reason.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
return $"{group.Key}=fresh:{freshCount.ToString(CultureInfo.InvariantCulture)}" +
|
||||
(freshCount == group.Count()
|
||||
? string.Empty
|
||||
: $"/blocked:{string.Join('+', blocked)}");
|
||||
});
|
||||
var blockReasons = results
|
||||
.Where(static result => !result.IsFresh)
|
||||
.GroupBy(static result => result.Reason, StringComparer.Ordinal)
|
||||
.ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
|
||||
var fresh = results.Count(static result => result.IsFresh);
|
||||
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_GRAMMAR_AUDIT: " +
|
||||
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"financial={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Financial).ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"netSell={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.NetSell).ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"vi={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Vi).ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"historical={candidates.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"canonical={candidates.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine("PLIST_MANUAL_FINANCIAL_SCREENS: " + string.Join(',', screenCounts));
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_FINANCIAL_OUTCOMES: " + string.Join(',', financialOutcomes));
|
||||
Console.WriteLine("PLIST_MANUAL_NET_SELL_AUDIENCES: " + string.Join(',', audienceCounts));
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_VI_GRAMMAR: " +
|
||||
$"historical={viRows.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"versioned={viRows.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_TRUSTED_SOURCES: " +
|
||||
$"netSellAudiencesReady={trustedSources.NetSellAudiencesReady.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"viSnapshotReady={trustedSources.ViSnapshotReady.ToString().ToLowerInvariant()} " +
|
||||
$"viItems={trustedSources.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"viPages={trustedSources.ViPageCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"openedHandle=true");
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_FRESH_PROOF: " +
|
||||
$"freshMaterialized={fresh.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"restoreBlocked={(results.Count - fresh).ToString(CultureInfo.InvariantCulture)} " +
|
||||
"graphE=double-read-row-version+fresh-stock-identity " +
|
||||
"netSell=opened-handle-production-parser " +
|
||||
"vi=opened-handle-version+identity+page-check " +
|
||||
"sceneDataAndAssetsNotAsserted=true");
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_BLOCK_REASONS: " +
|
||||
(blockReasons.Count == 0
|
||||
? "none"
|
||||
: string.Join(
|
||||
',',
|
||||
blockReasons.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair =>
|
||||
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
|
||||
Console.WriteLine(
|
||||
"PLIST_MANUAL_LOCAL_READ_AUDIT: " +
|
||||
$"runtimeState={runtime.State} " +
|
||||
$"entries={before.Entries.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"filesStable=true writesAttempted=false markerOrIntentCreated=false");
|
||||
|
||||
return new NamedManualRestoreReadOnlyAuditSummary(
|
||||
candidates.Count,
|
||||
fresh,
|
||||
results.Count - fresh,
|
||||
runtime.State,
|
||||
filesStable,
|
||||
kindCounts,
|
||||
blockReasons);
|
||||
}
|
||||
|
||||
public static string DefaultTrustedDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
throw InvalidBoundary("local application-data root");
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData"));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<LegacyNamedManualRestoreCandidate>> LoadCandidatesAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spec = new DataQuerySpec(
|
||||
Sql,
|
||||
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
|
||||
spec.ValidateFor(DataSourceKind.Oracle);
|
||||
var table = await executor.ExecuteAsync(
|
||||
DataSourceKind.Oracle,
|
||||
QueryName,
|
||||
spec,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
|
||||
if (table is null ||
|
||||
table.Columns.Count != columns.Length ||
|
||||
table.Rows.Count is < 1 or > MaximumRows)
|
||||
{
|
||||
throw InvalidBoundary("PList query schema or row bound");
|
||||
}
|
||||
|
||||
for (var index = 0; index < columns.Length; index++)
|
||||
{
|
||||
if (table.Columns[index].ColumnName != columns[index] ||
|
||||
table.Columns[index].DataType != typeof(string))
|
||||
{
|
||||
throw InvalidBoundary("PList query schema");
|
||||
}
|
||||
}
|
||||
|
||||
var candidates = new List<LegacyNamedManualRestoreCandidate>();
|
||||
var routedManualCount = 0;
|
||||
foreach (DataRow dataRow in table.Rows)
|
||||
{
|
||||
var parsed = LegacyPListReadAuditParser.Parse(
|
||||
dataRow[0] as string,
|
||||
dataRow[1] as string,
|
||||
dataRow[3] as string,
|
||||
dataRow[2] as string);
|
||||
if (!parsed.IsSuccess)
|
||||
{
|
||||
throw InvalidBoundary("PList row parse");
|
||||
}
|
||||
|
||||
var row = parsed.Row!;
|
||||
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
|
||||
if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
|
||||
{
|
||||
routedManualCount++;
|
||||
}
|
||||
|
||||
var candidate = LegacyNamedManualRestoreClassifier.Classify(row);
|
||||
if (candidate is not null)
|
||||
{
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
else if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
|
||||
{
|
||||
throw InvalidBoundary("manual route closed grammar");
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count != routedManualCount)
|
||||
{
|
||||
throw InvalidBoundary("manual route/classifier agreement");
|
||||
}
|
||||
|
||||
return candidates.AsReadOnly();
|
||||
}
|
||||
|
||||
private static async Task<RestoreResult> ResolveFinancialAsync(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
LegacyManualFinancialScreenService financialService,
|
||||
LegacyStockSearchService stockSearchService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var identity = new ManualFinancialIdentity(
|
||||
candidate.FinancialScreen!.Value,
|
||||
candidate.StockName!);
|
||||
var firstRead = await financialService.GetAsync(identity, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var query = LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate);
|
||||
var stockSearch = await stockSearchService.SearchAsync(
|
||||
query,
|
||||
BrowserStockSearchLimit,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var secondRead = await financialService.GetAsync(identity, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var failure = LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||
candidate,
|
||||
firstRead,
|
||||
secondRead,
|
||||
stockSearch);
|
||||
return RestoreResult.From(failure);
|
||||
}
|
||||
catch (ManualFinancialNotFoundException)
|
||||
{
|
||||
return RestoreResult.Blocked("GRAPH_E_NOT_FOUND");
|
||||
}
|
||||
catch (ManualFinancialAmbiguousIdentityException)
|
||||
{
|
||||
return RestoreResult.Blocked("GRAPH_E_AMBIGUOUS");
|
||||
}
|
||||
catch (ManualFinancialDataException)
|
||||
{
|
||||
return RestoreResult.Blocked("GRAPH_E_DATA_INVALID");
|
||||
}
|
||||
catch (StockSearchDataException)
|
||||
{
|
||||
return RestoreResult.Blocked("STOCK_MASTER_DATA_INVALID");
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return RestoreResult.Blocked("GRAPH_E_IDENTITY_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RestoreResult> ResolveNetSellAsync(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
S5025TrustedManualFileDataSource source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await source.ReadAsync(candidate.Audience!.Value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return RestoreResult.From(
|
||||
LegacyNamedManualFreshProof.EvaluateNetSell(candidate, rows));
|
||||
}
|
||||
catch (S5025TrustedManualDataException)
|
||||
{
|
||||
return RestoreResult.Blocked("NET_SELL_TRUSTED_READ_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TrustedSourceEvidence> ReadTrustedSourcesAsync(
|
||||
S5025TrustedManualFileDataSource netSellSource,
|
||||
ViTrustedManualFileStore viSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var audiencesReady = 0;
|
||||
foreach (var audience in Enum.GetValues<S5025ManualAudience>())
|
||||
{
|
||||
var rows = await netSellSource.ReadAsync(audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (rows.Count != 5)
|
||||
{
|
||||
throw InvalidBoundary("net-sell production parser");
|
||||
}
|
||||
|
||||
audiencesReady++;
|
||||
}
|
||||
|
||||
var vi = await viSource.ReadStockNameSnapshotAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!TrustedViManualReference.IsRowVersion(vi.RowVersion) ||
|
||||
vi.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount)
|
||||
{
|
||||
throw InvalidBoundary("VI production parser");
|
||||
}
|
||||
|
||||
var canonical = new LegacySceneSelection(
|
||||
"PAGED_VI",
|
||||
TrustedViManualReference.SubjectSentinel,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
vi.RowVersion);
|
||||
var secondRead = await TrustedViManualReference.ResolveAsync(
|
||||
canonical,
|
||||
pageIndexZeroBased: 0,
|
||||
viSource,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!secondRead.StockNames.SequenceEqual(vi.StockNames, StringComparer.Ordinal))
|
||||
{
|
||||
throw InvalidBoundary("VI stable second read");
|
||||
}
|
||||
|
||||
return new TrustedSourceEvidence(
|
||||
audiencesReady,
|
||||
ViSnapshotReady: true,
|
||||
vi.StockNames.Count,
|
||||
checked((vi.StockNames.Count + 4) / 5));
|
||||
}
|
||||
|
||||
private static async Task<RestoreResult> ResolveViAsync(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
ViTrustedManualFileStore source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await source.ReadStockNameSnapshotAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var failure = LegacyNamedManualFreshProof.EvaluateVi(candidate, snapshot);
|
||||
if (failure != LegacyNamedManualFreshProofFailure.None)
|
||||
{
|
||||
return RestoreResult.From(failure);
|
||||
}
|
||||
|
||||
var canonical = new LegacySceneSelection(
|
||||
"PAGED_VI",
|
||||
TrustedViManualReference.SubjectSentinel,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
snapshot.RowVersion);
|
||||
var resolved = await TrustedViManualReference.ResolveAsync(
|
||||
canonical,
|
||||
pageIndexZeroBased: 0,
|
||||
source,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return resolved.PageNumberOneBased == 1 &&
|
||||
resolved.StockNames.SequenceEqual(
|
||||
snapshot.StockNames,
|
||||
StringComparer.Ordinal)
|
||||
? RestoreResult.Fresh()
|
||||
: RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
|
||||
}
|
||||
catch (ViTrustedManualDataException)
|
||||
{
|
||||
return RestoreResult.Blocked("VI_TRUSTED_READ_INVALID");
|
||||
}
|
||||
catch (LegacySceneDataException)
|
||||
{
|
||||
return RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
|
||||
}
|
||||
}
|
||||
|
||||
private static DirectorySnapshot SnapshotDirectory(string trustedDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = new DirectoryInfo(trustedDirectory);
|
||||
if (!directory.Exists ||
|
||||
(directory.Attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) !=
|
||||
FileAttributes.Directory)
|
||||
{
|
||||
throw InvalidBoundary("operator directory");
|
||||
}
|
||||
|
||||
var entries = new List<FileSnapshot>();
|
||||
foreach (var path in Directory.EnumerateFileSystemEntries(trustedDirectory)
|
||||
.OrderBy(static value => Path.GetFileName(value), StringComparer.Ordinal))
|
||||
{
|
||||
var attributes = File.GetAttributes(path);
|
||||
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
|
||||
{
|
||||
throw InvalidBoundary("operator directory entry");
|
||||
}
|
||||
|
||||
var before = new FileInfo(path);
|
||||
byte[] digest;
|
||||
long length;
|
||||
using (var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4_096,
|
||||
FileOptions.SequentialScan))
|
||||
{
|
||||
length = stream.Length;
|
||||
digest = SHA256.HashData(stream);
|
||||
if (stream.Length != length)
|
||||
{
|
||||
throw InvalidBoundary("operator file stable read");
|
||||
}
|
||||
}
|
||||
|
||||
var after = new FileInfo(path);
|
||||
if (after.Length != length ||
|
||||
before.LastWriteTimeUtc != after.LastWriteTimeUtc ||
|
||||
before.CreationTimeUtc != after.CreationTimeUtc)
|
||||
{
|
||||
throw InvalidBoundary("operator file stable read");
|
||||
}
|
||||
|
||||
entries.Add(new FileSnapshot(
|
||||
Path.GetFileName(path),
|
||||
length,
|
||||
after.CreationTimeUtc,
|
||||
after.LastWriteTimeUtc,
|
||||
Convert.ToHexString(digest)));
|
||||
}
|
||||
|
||||
directory.Refresh();
|
||||
return new DirectorySnapshot(directory.LastWriteTimeUtc, entries.AsReadOnly());
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
IOException or
|
||||
UnauthorizedAccessException or
|
||||
ArgumentException or
|
||||
NotSupportedException)
|
||||
{
|
||||
throw InvalidBoundary("operator file snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
private static InvalidDataException InvalidBoundary(string detail) =>
|
||||
new($"The read-only named manual restore audit failed its {detail} boundary. " +
|
||||
"No source values were printed.");
|
||||
|
||||
private sealed record RestoreResult(bool IsFresh, string Reason)
|
||||
{
|
||||
public static RestoreResult Fresh() => new(true, "NONE");
|
||||
|
||||
public static RestoreResult Blocked(string reason) => new(false, reason);
|
||||
|
||||
public static RestoreResult From(LegacyNamedManualFreshProofFailure failure) =>
|
||||
failure == LegacyNamedManualFreshProofFailure.None
|
||||
? Fresh()
|
||||
: Blocked(failure.ToString().ToUpperInvariant());
|
||||
}
|
||||
|
||||
private sealed record TrustedSourceEvidence(
|
||||
int NetSellAudiencesReady,
|
||||
bool ViSnapshotReady,
|
||||
int ViItemCount,
|
||||
int ViPageCount);
|
||||
|
||||
private sealed record FileSnapshot(
|
||||
string Name,
|
||||
long Length,
|
||||
DateTime CreationTimeUtc,
|
||||
DateTime LastWriteTimeUtc,
|
||||
string Sha256);
|
||||
|
||||
private sealed record DirectorySnapshot(
|
||||
DateTime LastWriteTimeUtc,
|
||||
IReadOnlyList<FileSnapshot> Entries)
|
||||
{
|
||||
public bool Matches(DirectorySnapshot other) =>
|
||||
other is not null &&
|
||||
LastWriteTimeUtc == other.LastWriteTimeUtc &&
|
||||
Entries.SequenceEqual(other.Entries);
|
||||
}
|
||||
}
|
||||
802
tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs
Normal file
802
tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs
Normal file
@@ -0,0 +1,802 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
internal static class NamedPlaylistReadOnlyDbAudit
|
||||
{
|
||||
private const string QueryName = "AUDIT_ACTIVE_NAMED_PLAYLIST_ROWS";
|
||||
private const int MaximumRows = 20_000;
|
||||
|
||||
private const string Sql = """
|
||||
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
|
||||
FROM (
|
||||
SELECT TRIM(d.DC_CODE) DC_CODE,
|
||||
d.DC_TITLE DC_TITLE,
|
||||
p.LIST_TEXT LIST_TEXT,
|
||||
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
|
||||
FROM DC_LIST d
|
||||
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
|
||||
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
|
||||
)
|
||||
WHERE ROWNUM <= :row_limit
|
||||
""";
|
||||
|
||||
internal static async Task RunAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
Console.WriteLine("PLIST_READ_AUDIT: BEGIN");
|
||||
var rawRows = await LoadRawRowsAsync(executor, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine(
|
||||
$"PLIST_READ_AUDIT_RAW: rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
var persistence = new LegacyNamedPlaylistPersistenceService(executor);
|
||||
var definitions = await persistence
|
||||
.ListAsync(LegacyNamedPlaylistPersistenceService.MaximumDefinitions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (definitions.IsTruncated || definitions.Items.Count == 0)
|
||||
{
|
||||
throw InvalidBoundary("definition cardinality");
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"PLIST_READ_AUDIT_DEFINITIONS: count={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
var rawByProgram = rawRows
|
||||
.GroupBy(static row => row.ProgramCode, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
static group => group.Key,
|
||||
static group => (IReadOnlyList<LegacyPListAuditRow>)group.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
if (rawByProgram.Count != definitions.Items.Count ||
|
||||
rawRows.Count != rawByProgram.Values.Sum(static rows => rows.Count))
|
||||
{
|
||||
throw InvalidBoundary("active definition join");
|
||||
}
|
||||
|
||||
var loadedRows = 0;
|
||||
foreach (var definition in definitions.Items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!rawByProgram.TryGetValue(definition.ProgramCode, out var programRows) ||
|
||||
!LegacyPListReadAuditEvidence.HasContiguousIndexes(programRows) ||
|
||||
programRows.Any(row =>
|
||||
!string.Equals(row.ProgramTitle, definition.Title, StringComparison.Ordinal)))
|
||||
{
|
||||
throw InvalidBoundary("program identity or item order");
|
||||
}
|
||||
|
||||
var loaded = await persistence
|
||||
.LoadAsync(definition.ProgramCode, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (loaded.Definition != definition || loaded.Items.Count != programRows.Count)
|
||||
{
|
||||
throw InvalidBoundary("typed load cardinality");
|
||||
}
|
||||
|
||||
for (var index = 0; index < programRows.Count; index++)
|
||||
{
|
||||
if (!LegacyPListReadAuditEvidence.MatchesPersistenceResult(
|
||||
programRows[index],
|
||||
loaded.Items[index]))
|
||||
{
|
||||
throw InvalidBoundary("lossless typed load comparison");
|
||||
}
|
||||
}
|
||||
|
||||
loadedRows += loaded.Items.Count;
|
||||
}
|
||||
|
||||
if (loadedRows != rawRows.Count)
|
||||
{
|
||||
throw InvalidBoundary("typed load total");
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"PLIST_READ_AUDIT_TYPED: rows={loadedRows.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
IReadOnlySet<SelectionKey> fixedSignatures;
|
||||
try
|
||||
{
|
||||
fixedSignatures = LoadFixedSignatures();
|
||||
}
|
||||
catch (InvalidDataException exception) when (
|
||||
exception.Message.StartsWith(
|
||||
"The read-only PList audit failed its ",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine("PLIST_FIXED_MAPPER_BOUNDARY: " + exception.Message);
|
||||
throw;
|
||||
}
|
||||
Console.WriteLine(
|
||||
"PLIST_FIXED_MAPPER_AUDIT: " +
|
||||
$"sourceActions=328 unavailable=6 " +
|
||||
$"uniqueSignatures={fixedSignatures.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"sourceHashes=verified");
|
||||
var routedRows = rawRows
|
||||
.Select(row => new RoutedRow(row, ClassifyRoute(row, fixedSignatures)))
|
||||
.ToArray();
|
||||
var routeCounts = routedRows
|
||||
.GroupBy(static routed => routed.Route)
|
||||
.ToDictionary(static group => group.Key, static group => group.Count());
|
||||
MaterializationSummary outcomes;
|
||||
try
|
||||
{
|
||||
outcomes = await ResolveMaterializationAsync(
|
||||
executor,
|
||||
routedRows,
|
||||
routeCounts,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidDataException exception) when (
|
||||
exception.Message.StartsWith(
|
||||
"The read-only PList audit failed its ",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine("PLIST_MATERIALIZATION_BOUNDARY: " + exception.Message);
|
||||
throw;
|
||||
}
|
||||
if (outcomes.Playable + outcomes.Blocked != rawRows.Count)
|
||||
{
|
||||
throw InvalidBoundary("materialization total");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"PLIST_READ_AUDIT: " +
|
||||
$"definitions={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"parsed={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"lossless={rawRows.Count(static row => row.IsLossless).ToString(CultureInfo.InvariantCulture)} " +
|
||||
"replacement=0 mojibake=0 nonContiguousPrograms=0 typedMismatch=0");
|
||||
Console.WriteLine(
|
||||
"PLIST_ROUTE_AUDIT: " + string.Join(
|
||||
",",
|
||||
Enum.GetValues<LegacyPListAuditRoute>().Select(route =>
|
||||
$"{route}={routeCounts.GetValueOrDefault(route).ToString(CultureInfo.InvariantCulture)}")));
|
||||
Console.WriteLine(
|
||||
"PLIST_MATERIALIZATION_AUDIT: " +
|
||||
$"unique={outcomes.Unique.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"selectionMapped={outcomes.Playable.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"restoreBlocked={outcomes.Blocked.ToString(CultureInfo.InvariantCulture)} " +
|
||||
"sceneDataAndAssetsNotAsserted=true");
|
||||
Console.WriteLine(
|
||||
"PLIST_BLOCK_REASONS: " +
|
||||
(outcomes.BlockReasons.Count == 0
|
||||
? "none"
|
||||
: string.Join(
|
||||
",",
|
||||
outcomes.BlockReasons
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair =>
|
||||
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<LegacyPListAuditRow>> LoadRawRowsAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spec = new DataQuerySpec(
|
||||
Sql,
|
||||
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
|
||||
spec.ValidateFor(DataSourceKind.Oracle);
|
||||
var table = await executor
|
||||
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
|
||||
if (table is null || table.Columns.Count != columns.Length ||
|
||||
table.Rows.Count is < 1 or > MaximumRows)
|
||||
{
|
||||
throw InvalidBoundary("query schema or row bound");
|
||||
}
|
||||
|
||||
for (var index = 0; index < columns.Length; index++)
|
||||
{
|
||||
if (!string.Equals(
|
||||
table.Columns[index].ColumnName,
|
||||
columns[index],
|
||||
StringComparison.Ordinal) ||
|
||||
table.Columns[index].DataType != typeof(string))
|
||||
{
|
||||
throw InvalidBoundary("query schema");
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<LegacyPListAuditRow>(table.Rows.Count);
|
||||
foreach (DataRow dataRow in table.Rows)
|
||||
{
|
||||
var result = LegacyPListReadAuditParser.Parse(
|
||||
dataRow[0] as string,
|
||||
dataRow[1] as string,
|
||||
dataRow[3] as string,
|
||||
dataRow[2] as string);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
// The failure enum contains no source text. Never echo raw
|
||||
// operator data or identifiers from this audit boundary.
|
||||
throw InvalidBoundary($"row parse ({result.Failure})");
|
||||
}
|
||||
|
||||
rows.Add(result.Row!);
|
||||
}
|
||||
|
||||
return rows.AsReadOnly();
|
||||
}
|
||||
|
||||
private static async Task<MaterializationSummary> ResolveMaterializationAsync(
|
||||
IDataQueryExecutor executor,
|
||||
IReadOnlyList<RoutedRow> routedRows,
|
||||
IReadOnlyDictionary<LegacyPListAuditRoute, int> routeCounts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var summary = new MaterializationSummary();
|
||||
|
||||
// These closed classifiers create one immutable selection candidate.
|
||||
// This audit does not call the scene data loaders or assert asset and
|
||||
// PREPARE readiness. Industry fixed rows are independently constrained
|
||||
// to two markets and three actions by LegacyPListReadAuditEvidence.
|
||||
summary.AddPlayable(routeCounts.GetValueOrDefault(
|
||||
LegacyPListAuditRoute.ExistingClosedMapper));
|
||||
summary.AddPlayable(routeCounts.GetValueOrDefault(
|
||||
LegacyPListAuditRoute.IndustryFixedClosedMapper));
|
||||
|
||||
var comparisonRows = RowsFor(
|
||||
routedRows,
|
||||
LegacyPListAuditRoute.ComparisonFreshIdentity,
|
||||
static (row, index) => LegacyPListReadAuditEvidence
|
||||
.ToComparisonRow(row) with { ItemIndex = index });
|
||||
if (comparisonRows.Count > 0)
|
||||
{
|
||||
var resolved = await new LegacyNamedComparisonRestoreService(executor)
|
||||
.ResolveAsync(comparisonRows, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var row in resolved.Rows)
|
||||
{
|
||||
if (row.IsResolved)
|
||||
{
|
||||
summary.AddPlayable(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.AddBlocked("COMPARISON_" + row.Failure!.Value.ToString().ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foreignIndexRows = RowsFor(
|
||||
routedRows,
|
||||
LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity,
|
||||
static (row, index) => LegacyPListReadAuditEvidence
|
||||
.ToForeignIndexCandleRow(row) with { ItemIndex = index });
|
||||
if (foreignIndexRows.Count > 0)
|
||||
{
|
||||
var resolved = await new LegacyForeignIndexCandleRestoreService(executor)
|
||||
.ResolveAsync(foreignIndexRows, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var row in resolved.Rows)
|
||||
{
|
||||
if (row.IsResolved)
|
||||
{
|
||||
summary.AddPlayable(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.AddBlocked("FOREIGN_INDEX_" + row.Failure!.Value.ToString().ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nxtRows = RowsFor(
|
||||
routedRows,
|
||||
LegacyPListAuditRoute.NxtThemeFreshIdentity,
|
||||
static (row, index) => LegacyPListReadAuditEvidence
|
||||
.ToNxtThemeRow(row) with { ItemIndex = index });
|
||||
if (nxtRows.Count > 0)
|
||||
{
|
||||
var resolved = await new LegacyNamedNxtThemeRestoreService(executor)
|
||||
.ResolveAsync(nxtRows, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var row in resolved.Rows)
|
||||
{
|
||||
if (row.IsResolved)
|
||||
{
|
||||
summary.AddPlayable(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.AddBlocked("NXT_THEME_" + row.Failure!.Value.ToString().ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ResolveKrxThemesAsync(executor, routedRows, summary, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await ResolveForeignStocksAsync(executor, routedRows, summary, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// GraphE/FSell/VI require a fresh row-version or opened-handle trusted
|
||||
// file read. The DB-only audit intentionally does not claim those
|
||||
// external proofs and keeps every such row blocked with one reason.
|
||||
summary.AddBlocked(
|
||||
"MANUAL_TRUSTED_SOURCE_FRESH_READ_REQUIRED",
|
||||
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.ManualFreshMaterialization));
|
||||
summary.AddBlocked(
|
||||
"NO_UNIQUE_CLOSED_MAPPER",
|
||||
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.Unmapped));
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static async Task ResolveKrxThemesAsync(
|
||||
IDataQueryExecutor executor,
|
||||
IReadOnlyList<RoutedRow> rows,
|
||||
MaterializationSummary summary,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = RowsFor(
|
||||
rows,
|
||||
LegacyPListAuditRoute.KrxThemeFreshIdentity,
|
||||
static (row, index) => new LegacyNamedKrxThemeRestoreRow(
|
||||
index,
|
||||
row.IsEnabled,
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.PageTextForDisplay,
|
||||
row.DataCode));
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = await new LegacyNamedKrxThemeRestoreService(executor)
|
||||
.ResolveAsync(candidates, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var mapped = 0;
|
||||
var remapped = 0;
|
||||
foreach (var row in resolved.Rows)
|
||||
{
|
||||
if (row.IsResolved)
|
||||
{
|
||||
summary.AddPlayable(1);
|
||||
mapped++;
|
||||
if (row.Identity!.CodeWasRemapped)
|
||||
{
|
||||
remapped++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.AddBlocked("KRX_THEME_" + KrxThemeFailureToken(row.Failure!.Value));
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"PLIST_KRX_THEME_RESTORE: " +
|
||||
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"codeRemapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
private static async Task ResolveForeignStocksAsync(
|
||||
IDataQueryExecutor executor,
|
||||
IReadOnlyList<RoutedRow> rows,
|
||||
MaterializationSummary summary,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = RowsFor(
|
||||
rows,
|
||||
LegacyPListAuditRoute.ForeignStockFreshIdentity,
|
||||
static (row, index) => new LegacyNamedForeignStockRestoreRow(
|
||||
index,
|
||||
row.IsEnabled,
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.PageTextForDisplay,
|
||||
row.DataCode));
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync(candidates, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var mapped = 0;
|
||||
foreach (var row in resolved.Rows)
|
||||
{
|
||||
if (row.IsResolved)
|
||||
{
|
||||
summary.AddPlayable(1);
|
||||
mapped++;
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.AddBlocked(
|
||||
"FOREIGN_STOCK_" + ForeignStockFailureToken(row.Failure!.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var actions = resolved.Rows
|
||||
.Where(static row => row.IsResolved)
|
||||
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
|
||||
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
var databaseIssues = resolved.Rows
|
||||
.Where(static row =>
|
||||
row.Failure == LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid)
|
||||
.GroupBy(static row => row.DatabaseIssue?.ToString() ?? "UNCLASSIFIED", StringComparer.Ordinal)
|
||||
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
var slashSymbols = resolved.Rows.Count(static row =>
|
||||
row.IsResolved && row.Identity!.Symbol.Contains('/'));
|
||||
var nonAsciiSymbols = resolved.Rows.Count(static row =>
|
||||
row.IsResolved && row.Identity!.Symbol.Any(static character => !char.IsAscii(character)));
|
||||
var hangulSymbols = resolved.Rows.Count(static row =>
|
||||
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit));
|
||||
var internalSpaceSymbols = resolved.Rows.Count(static row =>
|
||||
row.IsResolved && row.Identity!.Symbol.Contains(' '));
|
||||
var hangulActions = resolved.Rows
|
||||
.Where(static row =>
|
||||
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit))
|
||||
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
|
||||
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine(
|
||||
"PLIST_FOREIGN_STOCK_RESTORE: " +
|
||||
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"slashSymbols={slashSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"nonAsciiSymbols={nonAsciiSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"hangulSymbols={hangulSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"internalSpaceSymbols={internalSpaceSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"actions={string.Join(',', actions)} " +
|
||||
$"hangulActions={string.Join(',', hangulActions)} " +
|
||||
$"databaseIssues={(databaseIssues.Any() ? string.Join(',', databaseIssues) : "none")} " +
|
||||
"rawValuesPrinted=false");
|
||||
}
|
||||
|
||||
private static string KrxThemeFailureToken(LegacyNamedKrxThemeRestoreFailure failure) =>
|
||||
failure switch
|
||||
{
|
||||
LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW",
|
||||
LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
|
||||
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
|
||||
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
|
||||
LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
|
||||
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(failure))
|
||||
};
|
||||
|
||||
private static string ForeignStockFailureToken(
|
||||
LegacyNamedForeignStockRestoreFailure failure) =>
|
||||
failure switch
|
||||
{
|
||||
LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
|
||||
LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
|
||||
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
|
||||
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(failure))
|
||||
};
|
||||
|
||||
private static IReadOnlyList<T> RowsFor<T>(
|
||||
IReadOnlyList<RoutedRow> rows,
|
||||
LegacyPListAuditRoute route,
|
||||
Func<LegacyPListAuditRow, int, T> convert) =>
|
||||
rows
|
||||
.Where(row => row.Route == route)
|
||||
.Select(static row => row.Row)
|
||||
.Select(convert)
|
||||
.ToArray();
|
||||
|
||||
private static bool HasExactStringColumn(DataColumn column, string name) =>
|
||||
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
||||
column.DataType == typeof(string);
|
||||
|
||||
private static bool IsHangulCodeUnit(char value) =>
|
||||
value is >= '\uAC00' and <= '\uD7AF' or
|
||||
>= '\u1100' and <= '\u11FF' or
|
||||
>= '\u3130' and <= '\u318F';
|
||||
|
||||
private static InvalidDataException InvalidBoundary(string detail) =>
|
||||
new($"The read-only PList audit failed its {detail} boundary. No source values were printed.");
|
||||
|
||||
private static LegacyPListAuditRoute ClassifyRoute(
|
||||
LegacyPListAuditRow row,
|
||||
IReadOnlySet<SelectionKey> fixedSignatures)
|
||||
{
|
||||
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
|
||||
if (route != LegacyPListAuditRoute.Unmapped)
|
||||
{
|
||||
return route;
|
||||
}
|
||||
|
||||
var key = new SelectionKey(
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.DataCode);
|
||||
return fixedSignatures.Contains(key)
|
||||
? LegacyPListAuditRoute.ExistingClosedMapper
|
||||
: LegacyPListAuditRoute.Unmapped;
|
||||
}
|
||||
|
||||
private static IReadOnlySet<SelectionKey> LoadFixedSignatures()
|
||||
{
|
||||
var profile = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
if (string.IsNullOrWhiteSpace(profile))
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed-profile root");
|
||||
}
|
||||
|
||||
var directory = Path.GetFullPath(Path.Combine(
|
||||
profile,
|
||||
"source",
|
||||
"repos",
|
||||
"MBN_STOCK_N",
|
||||
"MBN_STOCK_N",
|
||||
"bin",
|
||||
"Debug",
|
||||
"Res"));
|
||||
if (!Directory.Exists(directory) ||
|
||||
(File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed source directory");
|
||||
}
|
||||
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var encoding = Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
var signatureCounts = new Dictionary<SelectionKey, int>();
|
||||
var actionCount = 0;
|
||||
foreach (var source in FixedSources)
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(directory, source.FileName));
|
||||
if (!string.Equals(
|
||||
Path.GetDirectoryName(path),
|
||||
directory,
|
||||
StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(path) ||
|
||||
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed source file");
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
using (var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read))
|
||||
{
|
||||
if (stream.Length is < 1 or > 32_768)
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed source size");
|
||||
}
|
||||
|
||||
bytes = new byte[stream.Length];
|
||||
stream.ReadExactly(bytes);
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
Convert.ToHexString(SHA256.HashData(bytes)),
|
||||
source.ExpectedSha256,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed source hash");
|
||||
}
|
||||
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = encoding.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed source encoding");
|
||||
}
|
||||
|
||||
AppendFixedSignatures(signatureCounts, source.Market, text, ref actionCount);
|
||||
}
|
||||
|
||||
// The three audited runtime INI files contain 328 source actions. Six
|
||||
// unavailable manual placeholders are deliberately excluded below.
|
||||
if (actionCount != 322)
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed signature cardinality");
|
||||
}
|
||||
|
||||
// A raw row must identify exactly one current action. If two source
|
||||
// actions collapse to one historical five-field signature, retain no
|
||||
// signature for that key and let the row fail closed as unmapped.
|
||||
return signatureCounts
|
||||
.Where(static pair => pair.Value == 1)
|
||||
.Select(static pair => pair.Key)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private static void AppendFixedSignatures(
|
||||
IDictionary<SelectionKey, int> signatureCounts,
|
||||
string market,
|
||||
string text,
|
||||
ref int actionCount)
|
||||
{
|
||||
string? section = null;
|
||||
foreach (var rawLine in text.Split(["\r\n"], StringSplitOptions.None))
|
||||
{
|
||||
// MainForm's runtime INI loader calls Trim() on every line before
|
||||
// it builds the tree and playlist signature.
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Length >= 3 && line[0] == '[' && line[^1] == ']')
|
||||
{
|
||||
section = line[1..^1];
|
||||
if (section.Length == 0 || LegacyPListReadAuditParser.ContainsMojibakeSignal(section))
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed section");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section is null || line.Contains('^') ||
|
||||
LegacyPListReadAuditParser.ContainsMojibakeSignal(line))
|
||||
{
|
||||
throw InvalidBoundary("legacy fixed action");
|
||||
}
|
||||
|
||||
if (line == "VI 발동(수동)" ||
|
||||
section == "매매동향" && line is
|
||||
("개인 순매도 상위(수동)" or
|
||||
"외국인 순매도 상위(수동)" or
|
||||
"기관 순매도 상위(수동)"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
actionCount = checked(actionCount + 1);
|
||||
|
||||
var separator = line.IndexOf('_');
|
||||
var first = separator < 0 ? line : line[..separator];
|
||||
var remainder = separator < 0 ? string.Empty : line[(separator + 1)..];
|
||||
SelectionKey signature;
|
||||
if (market is "overseas" or "exchange")
|
||||
{
|
||||
signature = new SelectionKey(
|
||||
market == "overseas" ? "해외지수" : "환율",
|
||||
first,
|
||||
section,
|
||||
remainder,
|
||||
string.Empty);
|
||||
}
|
||||
else if (section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
|
||||
{
|
||||
signature = new SelectionKey(
|
||||
FixedIndexGroup(line),
|
||||
"지수",
|
||||
section,
|
||||
line,
|
||||
string.Empty);
|
||||
}
|
||||
else if (section == "매매동향")
|
||||
{
|
||||
signature = new SelectionKey(
|
||||
FixedIndexGroup(line),
|
||||
"지수",
|
||||
first,
|
||||
remainder,
|
||||
string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
signature = new SelectionKey(
|
||||
section,
|
||||
"지수",
|
||||
first,
|
||||
remainder,
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
signatureCounts.TryGetValue(signature, out var existingCount);
|
||||
signatureCounts[signature] = checked(existingCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FixedIndexGroup(string label)
|
||||
{
|
||||
if (label.Contains("코스피_NXT", StringComparison.Ordinal)) return "코스피_NXT";
|
||||
if (label.Contains("코스닥_NXT", StringComparison.Ordinal)) return "코스닥_NXT";
|
||||
if (label.Contains("코스피", StringComparison.Ordinal)) return "코스피";
|
||||
if (label.Contains("코스닥", StringComparison.Ordinal)) return "코스닥";
|
||||
return "전체";
|
||||
}
|
||||
|
||||
private sealed class MaterializationSummary
|
||||
{
|
||||
public int Unique { get; private set; }
|
||||
|
||||
public int Playable { get; private set; }
|
||||
|
||||
public int Blocked { get; private set; }
|
||||
|
||||
public Dictionary<string, int> BlockReasons { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public void AddPlayable(int count)
|
||||
{
|
||||
if (count < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
}
|
||||
|
||||
Unique = checked(Unique + count);
|
||||
Playable = checked(Playable + count);
|
||||
}
|
||||
|
||||
public void AddBlocked(string reason, int count = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason) || count < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
}
|
||||
|
||||
Unique = checked(Unique + count);
|
||||
Blocked = checked(Blocked + count);
|
||||
BlockReasons[reason] = checked(BlockReasons.GetValueOrDefault(reason) + count);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RoutedRow(
|
||||
LegacyPListAuditRow Row,
|
||||
LegacyPListAuditRoute Route);
|
||||
|
||||
private readonly record struct SelectionKey(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
private sealed record FixedSource(
|
||||
string FileName,
|
||||
string Market,
|
||||
string ExpectedSha256);
|
||||
|
||||
private static readonly IReadOnlyList<FixedSource> FixedSources =
|
||||
[
|
||||
new(
|
||||
"해외.ini",
|
||||
"overseas",
|
||||
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
|
||||
new(
|
||||
"환율.ini",
|
||||
"exchange",
|
||||
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
|
||||
new(
|
||||
"지수.ini",
|
||||
"index",
|
||||
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
if (args.Length == 1 &&
|
||||
args[0].Equals("--manual-import-audit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return RunManualImportAudit();
|
||||
}
|
||||
|
||||
if (args.Length == 1 &&
|
||||
args[0].Equals("--manual-import-fixture", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await RunManualImportFixtureAsync();
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
@@ -38,7 +51,16 @@ try
|
||||
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
|
||||
await NxtThemeRestoreDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
||||
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
||||
await NamedManualRestoreReadOnlyAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
|
||||
cancellation.Token);
|
||||
await NxtThemeRestoreDbAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
runtime.Options.MariaDb,
|
||||
cancellation.Token);
|
||||
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
|
||||
|
||||
var service = new LegacyMarketDataService(runtime.Executor);
|
||||
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
|
||||
@@ -123,7 +145,362 @@ static string? GetConfigurationPath(string[] arguments)
|
||||
}
|
||||
|
||||
throw new DatabaseConfigurationException(
|
||||
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>]");
|
||||
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]");
|
||||
}
|
||||
|
||||
static async Task VerifyLegacyComparisonImportAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string expectedSourceSha256 =
|
||||
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
|
||||
var result = await new LegacyComparisonPairImportService(
|
||||
executor,
|
||||
TrustedLegacyComparisonFileSource.CreateDefault())
|
||||
.ImportAsync(cancellationToken);
|
||||
if (!string.Equals(
|
||||
result.SourceSha256,
|
||||
expectedSourceSha256,
|
||||
StringComparison.Ordinal) ||
|
||||
result.SourceRowCount != 8 ||
|
||||
result.Pairs.Count != 8 ||
|
||||
result.Pairs.Where((pair, index) => pair.RowNumber != index + 1).Any())
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The fixed legacy comparison import did not reproduce its audited 8-row identity set.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"LegacyComparisonImport: rows={result.SourceRowCount.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"sha256={result.SourceSha256}, identities=fresh-read-only");
|
||||
}
|
||||
|
||||
static async Task<int> RunManualImportFixtureAsync()
|
||||
{
|
||||
const string expectedSourceSha256 =
|
||||
"fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114";
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
|
||||
var profile = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: FAIL - required profile roots are unavailable.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceDirectory = Path.Combine(
|
||||
profile,
|
||||
"source",
|
||||
"repos",
|
||||
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
||||
var operatingDirectory = Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData");
|
||||
var temporaryDirectory = Path.GetFullPath(Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW-manual-import-fixture-" + Guid.NewGuid().ToString("N")));
|
||||
var expectedDataFiles = new[]
|
||||
{
|
||||
"개인.dat",
|
||||
"외국인.dat",
|
||||
"기관.dat",
|
||||
"VI발동.dat"
|
||||
};
|
||||
var trackedOperatingFiles = expectedDataFiles.Concat(new[]
|
||||
{
|
||||
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||
LegacyManualOperatorDataImporter.IntentFileName
|
||||
}).ToArray();
|
||||
|
||||
var sourceBefore = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||
var operatingBefore = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
||||
try
|
||||
{
|
||||
var destination = TrustedManualDirectory.PreparePrivateWritableDirectory(
|
||||
temporaryDirectory);
|
||||
LegacyManualOperatorImportResult result;
|
||||
LegacyManualOperatorImportException? secondFailure = null;
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destination,
|
||||
new FixedLegacyManualOperatorDataSource()))
|
||||
{
|
||||
result = await importer.ImportAsync(cancellation.Token);
|
||||
try
|
||||
{
|
||||
_ = await importer.ImportAsync(cancellation.Token);
|
||||
}
|
||||
catch (LegacyManualOperatorImportException exception)
|
||||
{
|
||||
secondFailure = exception;
|
||||
}
|
||||
}
|
||||
|
||||
var sourceAfter = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||
var operatingAfter = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
||||
var fixtureFiles = SnapshotFiles(destination, trackedOperatingFiles);
|
||||
var dataHashesMatch = expectedDataFiles.All(name =>
|
||||
sourceBefore.TryGetValue(name, out var sourceValue) &&
|
||||
fixtureFiles.TryGetValue(name, out var fixtureValue) &&
|
||||
string.Equals(sourceValue, fixtureValue, StringComparison.Ordinal));
|
||||
var markerPresent = fixtureFiles.TryGetValue(
|
||||
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||
out var markerValue) && markerValue != "missing";
|
||||
var intentAbsent = fixtureFiles.TryGetValue(
|
||||
LegacyManualOperatorDataImporter.IntentFileName,
|
||||
out var intentValue) && intentValue == "missing";
|
||||
var runtimeState = LegacyManualOperatorDataRuntimeGuard.Inspect(destination).State;
|
||||
|
||||
if (!SnapshotsEqual(sourceBefore, sourceAfter) ||
|
||||
!SnapshotsEqual(operatingBefore, operatingAfter) ||
|
||||
!dataHashesMatch || !markerPresent || !intentAbsent ||
|
||||
result.MarkerVersion != LegacyManualOperatorDataImporter.MarkerVersion ||
|
||||
result.NetSellRowCount != 15 ||
|
||||
result.ViItemCount != 9 ||
|
||||
!string.Equals(
|
||||
result.SourceSha256,
|
||||
expectedSourceSha256,
|
||||
StringComparison.Ordinal) ||
|
||||
runtimeState != LegacyManualOperatorDataRuntimeState.ReadyImportedData ||
|
||||
secondFailure is null ||
|
||||
secondFailure.Failure != LegacyManualOperatorImportFailure.AlreadyImported ||
|
||||
secondFailure.OutcomeUnknown)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"MANUAL_IMPORT_FIXTURE: FAIL - an isolated import invariant was not reproduced.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"MANUAL_IMPORT_FIXTURE: PASS rows={result.NetSellRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"vi={result.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"sourceSha256={result.SourceSha256} state={runtimeState} " +
|
||||
"secondAttempt=AlreadyImported outcomeUnknown=false " +
|
||||
"sourceUnchanged=true operatingImportSetUnchanged=true");
|
||||
return 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: CANCELED_OR_TIMED_OUT");
|
||||
return 3;
|
||||
}
|
||||
catch (LegacyManualOperatorImportException exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"MANUAL_IMPORT_FIXTURE: FAIL - failure={exception.Failure} " +
|
||||
$"outcomeUnknown={exception.OutcomeUnknown.ToString().ToLowerInvariant()}");
|
||||
return exception.OutcomeUnknown ? 5 : 1;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"MANUAL_IMPORT_FIXTURE: FAIL - {exception.GetType().Name}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
RemoveOwnedFixtureDirectory(temporaryDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
static int RunManualImportAudit()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
Console.Error.WriteLine("MANUAL_IMPORT_AUDIT: FAIL - required profile roots are unavailable.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceDirectory = Path.Combine(
|
||||
profile,
|
||||
"source",
|
||||
"repos",
|
||||
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
||||
var operatingDirectory = Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData");
|
||||
var expectedDataFiles = new[]
|
||||
{
|
||||
"개인.dat",
|
||||
"외국인.dat",
|
||||
"기관.dat",
|
||||
"VI발동.dat"
|
||||
};
|
||||
var source = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||
var operating = SnapshotFiles(
|
||||
operatingDirectory,
|
||||
expectedDataFiles.Concat(new[]
|
||||
{
|
||||
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||
LegacyManualOperatorDataImporter.IntentFileName
|
||||
}));
|
||||
var markerPresent = operating[LegacyManualOperatorDataImporter.MarkerFileName] != "missing";
|
||||
var intentPresent = operating[LegacyManualOperatorDataImporter.IntentFileName] != "missing";
|
||||
var dataPresent = expectedDataFiles.Count(name => operating[name] != "missing");
|
||||
var sourcePresent = expectedDataFiles.Count(name => source[name] != "missing");
|
||||
var byteIdentical = expectedDataFiles.All(name =>
|
||||
source[name] != "missing" &&
|
||||
string.Equals(source[name], operating[name], StringComparison.Ordinal));
|
||||
var stagedPresent = Directory.Exists(operatingDirectory) &&
|
||||
Directory.EnumerateFileSystemEntries(
|
||||
operatingDirectory,
|
||||
".legacy-manual-import-v1-*",
|
||||
SearchOption.TopDirectoryOnly).Any();
|
||||
var runtimeState = LegacyManualOperatorDataRuntimeGuard
|
||||
.Inspect(operatingDirectory)
|
||||
.State;
|
||||
|
||||
var (decision, outcomeUnknown) = runtimeState switch
|
||||
{
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport =>
|
||||
("InterruptedImport", true),
|
||||
LegacyManualOperatorDataRuntimeState.InvalidImportedState =>
|
||||
("InvalidImportedState", false),
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData =>
|
||||
("AlreadyImported", false),
|
||||
_ when dataPresent > 0 || stagedPresent =>
|
||||
("DestinationNotEmpty", false),
|
||||
_ when sourcePresent == expectedDataFiles.Length =>
|
||||
("Eligible", false),
|
||||
_ => ("SourceUnavailable", false)
|
||||
};
|
||||
Console.WriteLine(
|
||||
$"MANUAL_IMPORT_AUDIT: PASS sourceFiles={sourcePresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
||||
$"operatingFiles={dataPresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
||||
$"sourceByteIdentical={byteIdentical.ToString().ToLowerInvariant()} " +
|
||||
$"marker={markerPresent.ToString().ToLowerInvariant()} " +
|
||||
$"intent={intentPresent.ToString().ToLowerInvariant()} " +
|
||||
$"staged={stagedPresent.ToString().ToLowerInvariant()} " +
|
||||
$"runtime={runtimeState} " +
|
||||
$"decision={decision} outcomeUnknown={outcomeUnknown.ToString().ToLowerInvariant()} " +
|
||||
"writesAttempted=false");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"MANUAL_IMPORT_AUDIT: FAIL - {exception.GetType().Name}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static IReadOnlyDictionary<string, string> SnapshotFiles(
|
||||
string directory,
|
||||
IEnumerable<string> fileNames)
|
||||
{
|
||||
const long maximumAuditFileBytes = 32 * 1024;
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var fileName in fileNames)
|
||||
{
|
||||
var path = Path.Combine(directory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
result.Add(fileName, "missing");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
if (stream.Length is < 0 or > maximumAuditFileBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"A manual-import audit file exceeded its closed size boundary.");
|
||||
}
|
||||
|
||||
var bytes = new byte[checked((int)stream.Length)];
|
||||
stream.ReadExactly(bytes);
|
||||
if (stream.ReadByte() != -1)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"A manual-import audit file changed beyond its closed size boundary.");
|
||||
}
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||
result.Add(
|
||||
fileName,
|
||||
bytes.Length.ToString(CultureInfo.InvariantCulture) + ":" + hash);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool SnapshotsEqual(
|
||||
IReadOnlyDictionary<string, string> first,
|
||||
IReadOnlyDictionary<string, string> second) =>
|
||||
first.Count == second.Count && first.All(pair =>
|
||||
second.TryGetValue(pair.Key, out var value) &&
|
||||
string.Equals(pair.Value, value, StringComparison.Ordinal));
|
||||
|
||||
static void RemoveOwnedFixtureDirectory(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var temporaryRoot = Path.TrimEndingDirectorySeparator(
|
||||
Path.GetFullPath(Path.GetTempPath()));
|
||||
var resolved = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
var prefix = temporaryRoot + Path.DirectorySeparatorChar;
|
||||
const string ownedNamePrefix = "MBN_STOCK_WEBVIEW-manual-import-fixture-";
|
||||
var ownedName = Path.GetFileName(resolved);
|
||||
var ownedSuffix = ownedName.StartsWith(ownedNamePrefix, StringComparison.Ordinal)
|
||||
? ownedName[ownedNamePrefix.Length..]
|
||||
: string.Empty;
|
||||
if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
ownedSuffix.Length != 32 ||
|
||||
ownedSuffix.Any(static value => !char.IsAsciiHexDigit(value)) ||
|
||||
(File.GetAttributes(resolved) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Refusing to remove a directory outside the owned fixture boundary.");
|
||||
}
|
||||
|
||||
var pendingDirectories = new Stack<string>();
|
||||
pendingDirectories.Push(resolved);
|
||||
while (pendingDirectories.TryPop(out var current))
|
||||
{
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(
|
||||
current,
|
||||
"*",
|
||||
new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
ReturnSpecialDirectories = false,
|
||||
AttributesToSkip = 0
|
||||
}))
|
||||
{
|
||||
var attributes = File.GetAttributes(entry);
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Refusing to recursively remove a fixture containing a reparse point.");
|
||||
}
|
||||
|
||||
if ((attributes & FileAttributes.Directory) != 0)
|
||||
{
|
||||
pendingDirectories.Push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Directory.Delete(resolved, recursive: true);
|
||||
}
|
||||
|
||||
static async Task VerifyOracleServerVersionAsync(
|
||||
|
||||
Reference in New Issue
Block a user