fix: serialize database activity during playout
This commit is contained in:
@@ -170,9 +170,14 @@ public sealed partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var databaseActivityEntered = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||||
|
CancelActiveMarketDataRequest();
|
||||||
|
CancelActiveDatabaseHealthCheck();
|
||||||
|
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
|
||||||
|
databaseActivityEntered = true;
|
||||||
if (IsBrowserCorrelationQuarantined())
|
if (IsBrowserCorrelationQuarantined())
|
||||||
{
|
{
|
||||||
PostPlayoutCommandError(
|
PostPlayoutCommandError(
|
||||||
@@ -308,6 +313,10 @@ public sealed partial class MainWindow
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
||||||
|
if (databaseActivityEntered)
|
||||||
|
{
|
||||||
|
_databaseActivityGate.Release();
|
||||||
|
}
|
||||||
_playoutCommandGate.Release();
|
_playoutCommandGate.Release();
|
||||||
// A browser-side timeout may have discarded the correlated result while the
|
// A browser-side timeout may have discarded the correlated result while the
|
||||||
// native operation was still completing. Always publish the authoritative
|
// native operation was still completing. Always publish the authoritative
|
||||||
@@ -760,6 +769,7 @@ public sealed partial class MainWindow
|
|||||||
{
|
{
|
||||||
await Task.Delay(nextDelay, token);
|
await Task.Delay(nextDelay, token);
|
||||||
await _playoutCommandGate.WaitAsync(token);
|
await _playoutCommandGate.WaitAsync(token);
|
||||||
|
var databaseActivityEntered = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
||||||
@@ -769,6 +779,10 @@ public sealed partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||||
|
CancelActiveMarketDataRequest();
|
||||||
|
CancelActiveDatabaseHealthCheck();
|
||||||
|
await _databaseActivityGate.WaitAsync(token);
|
||||||
|
databaseActivityEntered = true;
|
||||||
var result = await workflow.RefreshOnAirAsync(token);
|
var result = await workflow.RefreshOnAirAsync(token);
|
||||||
// A failed, timed-out, or unknown update never repeats. The operator
|
// A failed, timed-out, or unknown update never repeats. The operator
|
||||||
// must reconcile native/PGM state before starting another command.
|
// must reconcile native/PGM state before starting another command.
|
||||||
@@ -819,6 +833,10 @@ public sealed partial class MainWindow
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
||||||
|
if (databaseActivityEntered)
|
||||||
|
{
|
||||||
|
_databaseActivityGate.Release();
|
||||||
|
}
|
||||||
_playoutCommandGate.Release();
|
_playoutCommandGate.Release();
|
||||||
QueuePlayoutStatus();
|
QueuePlayoutStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ public sealed partial class MainWindow : Window
|
|||||||
private const string AppHost = "app.mbn.local";
|
private const string AppHost = "app.mbn.local";
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||||
|
private readonly SemaphoreSlim _databaseActivityGate = new(1, 1);
|
||||||
private DatabaseRuntime? _databaseRuntime;
|
private DatabaseRuntime? _databaseRuntime;
|
||||||
private IMarketDataService? _marketDataService;
|
private IMarketDataService? _marketDataService;
|
||||||
private CancellationTokenSource? _marketDataCancellation;
|
private CancellationTokenSource? _marketDataCancellation;
|
||||||
|
private CancellationTokenSource? _databaseHealthCancellation;
|
||||||
|
private long _databaseStatusSequence;
|
||||||
private string? _databaseInitializationError;
|
private string? _databaseInitializationError;
|
||||||
private bool _webViewReady;
|
private bool _webViewReady;
|
||||||
|
|
||||||
@@ -273,10 +276,29 @@ public sealed partial class MainWindow : Window
|
|||||||
previousCancellation?.Cancel();
|
previousCancellation?.Cancel();
|
||||||
previousCancellation?.Dispose();
|
previousCancellation?.Dispose();
|
||||||
|
|
||||||
|
var databaseActivityEntered = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
|
||||||
|
{
|
||||||
|
requestCancellation.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _databaseActivityGate.WaitAsync(requestCancellation.Token);
|
||||||
|
databaseActivityEntered = true;
|
||||||
|
|
||||||
|
// Close the race where playout starts after this request is published
|
||||||
|
// but before its market query begins.
|
||||||
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
|
||||||
|
{
|
||||||
|
requestCancellation.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var snapshot = await _marketDataService
|
var snapshot = await _marketDataService
|
||||||
.GetAsync(view, maximumRows, requestCancellation.Token);
|
.GetAsync(view, maximumRows, requestCancellation.Token);
|
||||||
|
requestCancellation.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
PostMessage("market-data", new
|
PostMessage("market-data", new
|
||||||
{
|
{
|
||||||
@@ -309,6 +331,11 @@ public sealed partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
if (databaseActivityEntered)
|
||||||
|
{
|
||||||
|
_databaseActivityGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
if (ReferenceEquals(Interlocked.CompareExchange(
|
if (ReferenceEquals(Interlocked.CompareExchange(
|
||||||
ref _marketDataCancellation,
|
ref _marketDataCancellation,
|
||||||
null,
|
null,
|
||||||
@@ -329,23 +356,109 @@ public sealed partial class MainWindow : Window
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PostDatabaseStatusAsync(CancellationToken cancellationToken)
|
private async Task PostDatabaseStatusAsync(
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
bool periodic = false)
|
||||||
{
|
{
|
||||||
if (!_webViewReady)
|
if (!_webViewReady)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (periodic && Volatile.Read(ref _playoutCommandInFlight) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool entered;
|
||||||
|
if (periodic)
|
||||||
|
{
|
||||||
|
entered = await _databaseActivityGate.WaitAsync(0, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _databaseActivityGate.WaitAsync(cancellationToken);
|
||||||
|
entered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entered)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var retryAfterPlayout = false;
|
||||||
|
CancellationTokenSource? healthCancellation = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
|
||||||
|
{
|
||||||
|
retryAfterPlayout = !periodic;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
Volatile.Write(ref _databaseHealthCancellation, healthCancellation);
|
||||||
|
|
||||||
|
// Close the race where playout starts after this health request
|
||||||
|
// entered the gate but before its diagnostic query begins.
|
||||||
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
|
||||||
|
{
|
||||||
|
healthCancellation.Cancel();
|
||||||
|
retryAfterPlayout = !periodic;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await QueryAndPostDatabaseStatusAsync(healthCancellation.Token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (healthCancellation?.IsCancellationRequested == true)
|
||||||
|
{
|
||||||
|
// Playout has priority over diagnostics. Interactive requests resume
|
||||||
|
// after the command; the periodic monitor keeps the last Web snapshot.
|
||||||
|
retryAfterPlayout = !periodic;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (healthCancellation is not null)
|
||||||
|
{
|
||||||
|
Interlocked.CompareExchange(
|
||||||
|
ref _databaseHealthCancellation,
|
||||||
|
null,
|
||||||
|
healthCancellation);
|
||||||
|
healthCancellation.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_databaseActivityGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!retryAfterPlayout)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the already-active command enter the activity gate before an
|
||||||
|
// interactive health request retries.
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(25), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task QueryAndPostDatabaseStatusAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
if (_databaseRuntime is null)
|
if (_databaseRuntime is null)
|
||||||
{
|
{
|
||||||
var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
|
var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
|
||||||
PostMessage("database-status", new
|
PostDatabaseStatus(new[]
|
||||||
{
|
{
|
||||||
sources = new[]
|
CreateUnavailableStatus("oracle", message),
|
||||||
{
|
CreateUnavailableStatus("mariaDb", message)
|
||||||
CreateUnavailableStatus("oracle", message),
|
|
||||||
CreateUnavailableStatus("mariaDb", message)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,41 +468,71 @@ public sealed partial class MainWindow : Window
|
|||||||
var statuses = await _databaseRuntime.HealthService
|
var statuses = await _databaseRuntime.HealthService
|
||||||
.CheckAllAsync(cancellationToken);
|
.CheckAllAsync(cancellationToken);
|
||||||
|
|
||||||
PostMessage("database-status", new
|
PostDatabaseStatus(statuses.Select(status => new
|
||||||
{
|
{
|
||||||
sources = statuses.Select(status => new
|
source = ToWireValue(status.Source),
|
||||||
{
|
configured = _databaseRuntime.Options.IsConfigured(status.Source),
|
||||||
source = ToWireValue(status.Source),
|
healthy = status.State == DatabaseHealthState.Healthy,
|
||||||
configured = _databaseRuntime.Options.IsConfigured(status.Source),
|
state = status.State.ToString().ToLowerInvariant(),
|
||||||
healthy = status.State == DatabaseHealthState.Healthy,
|
message = status.UserMessage,
|
||||||
state = status.State.ToString().ToLowerInvariant(),
|
checkedAt = status.CheckedAt,
|
||||||
message = status.UserMessage,
|
latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds)
|
||||||
checkedAt = status.CheckedAt,
|
}));
|
||||||
latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
PostMessage("database-status", new
|
PostDatabaseStatus(new[]
|
||||||
{
|
{
|
||||||
sources = new[]
|
CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
|
||||||
{
|
CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.")
|
||||||
CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
|
|
||||||
CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.")
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void PostDatabaseStatus(object sources)
|
||||||
|
{
|
||||||
|
PostMessage("database-status", new
|
||||||
|
{
|
||||||
|
sequence = Interlocked.Increment(ref _databaseStatusSequence),
|
||||||
|
sources
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelActiveDatabaseHealthCheck()
|
||||||
|
{
|
||||||
|
var cancellation = Volatile.Read(ref _databaseHealthCancellation);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cancellation?.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Completion won the race after the reference was read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelActiveMarketDataRequest()
|
||||||
|
{
|
||||||
|
var cancellation = Volatile.Read(ref _marketDataCancellation);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cancellation?.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Completion won the race after the reference was read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
|
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await PostDatabaseStatusAsync(cancellationToken);
|
await PostDatabaseStatusAsync(cancellationToken, periodic: true);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -538,6 +681,7 @@ public sealed partial class MainWindow : Window
|
|||||||
var wasWebViewReady = _webViewReady;
|
var wasWebViewReady = _webViewReady;
|
||||||
_webViewReady = false;
|
_webViewReady = false;
|
||||||
_lifetimeCancellation.Cancel();
|
_lifetimeCancellation.Cancel();
|
||||||
|
CancelActiveDatabaseHealthCheck();
|
||||||
ShutdownPlayoutRuntime();
|
ShutdownPlayoutRuntime();
|
||||||
var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
|
var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
|
||||||
requestCancellation?.Cancel();
|
requestCancellation?.Cancel();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
|
|
||||||
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비의 Tornado2 본체에는 렌더 API가 없는 전용 진단으로 실제 `KTAPConnect → Disconnect`를 수행했고, 별도로 승인된 고정 runner로 PGM의 `5001 → 5006 → TAKE OUT` 화면과 Network Monitoring의 `HELLO / LOAD_SCENE / SCENE_PREPARE / PLAY / STOPAL / BYE`를 확인했습니다. 이 성공은 일반 앱의 Live 이중 승인 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
|
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비의 Tornado2 본체에는 렌더 API가 없는 전용 진단으로 실제 `KTAPConnect → Disconnect`를 수행했고, 별도로 승인된 고정 runner로 PGM의 `5001 → 5006 → TAKE OUT` 화면과 Network Monitoring의 `HELLO / LOAD_SCENE / SCENE_PREPARE / PLAY / STOPAL / BYE`를 확인했습니다. 이 성공은 일반 앱의 Live 이중 승인 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
|
||||||
|
|
||||||
|
2026-07-11의 첫 MSIX WebView Live 회차는 CONNECT와 `PREPARE 5001/page 1`까지 성공했지만, fresh TAKE IN 직전 Oracle 조회가 `DATABASE_UNAVAILABLE`로 명확히 실패해 K3D `PLAY` 없이 종료됐습니다. TAKE IN은 반복하지 않았고 Network Monitoring의 `STOPAL / UNLOAD_SCENE / BYE`와 검은 PGM을 확인했습니다. 회차 종료 뒤 Oracle/MariaDB 55-query 스모크는 다시 통과했지만, 실제 WebView TAKE IN·refresh·playlist/Page NEXT 동등성은 아직 완료가 아니며 새 동결 계획과 승인 회차가 필요합니다.
|
||||||
|
|
||||||
## Visual Studio 2026에서 실행
|
## Visual Studio 2026에서 실행
|
||||||
|
|
||||||
1. `MBN_STOCK_WEBVIEW.sln`을 엽니다.
|
1. `MBN_STOCK_WEBVIEW.sln`을 엽니다.
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
error: null,
|
error: null,
|
||||||
lastSignature: ""
|
lastSignature: ""
|
||||||
},
|
},
|
||||||
database: { sources: [], loading: true, lastSignature: "" },
|
database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 },
|
||||||
liveData: {
|
liveData: {
|
||||||
requestId: null,
|
requestId: null,
|
||||||
view: null,
|
view: null,
|
||||||
@@ -1400,6 +1400,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDatabaseStatus(payload) {
|
function handleDatabaseStatus(payload) {
|
||||||
|
const sequence = payload?.sequence;
|
||||||
|
if (!playoutSafety.isNewerDatabaseStatusSequence(
|
||||||
|
state.database.latestSequence,
|
||||||
|
sequence)) return;
|
||||||
|
|
||||||
|
state.database.latestSequence = sequence;
|
||||||
state.database.sources = normalizedDatabaseSources(payload?.sources);
|
state.database.sources = normalizedDatabaseSources(payload?.sources);
|
||||||
state.database.loading = false;
|
state.database.loading = false;
|
||||||
renderDatabaseStatus();
|
renderDatabaseStatus();
|
||||||
|
|||||||
@@ -69,6 +69,14 @@
|
|||||||
return !refreshFaulted && error?.isRefreshFault === true;
|
return !refreshFaulted && error?.isRefreshFault === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNewerDatabaseStatusSequence(latestSequence, incomingSequence) {
|
||||||
|
const latest = Number.isSafeInteger(latestSequence) && latestSequence >= 0
|
||||||
|
? latestSequence
|
||||||
|
: 0;
|
||||||
|
return Number.isSafeInteger(incomingSequence) &&
|
||||||
|
incomingSequence > 0 && incomingSequence > latest;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveStoredCatalogIndex(catalog, builderKey, cutCode) {
|
function resolveStoredCatalogIndex(catalog, builderKey, cutCode) {
|
||||||
if (!Array.isArray(catalog)) return -1;
|
if (!Array.isArray(catalog)) return -1;
|
||||||
const exact = catalog.findIndex(item =>
|
const exact = catalog.findIndex(item =>
|
||||||
@@ -90,6 +98,7 @@
|
|||||||
canClearCommandError,
|
canClearCommandError,
|
||||||
playlistSnapshotLocked,
|
playlistSnapshotLocked,
|
||||||
shouldClearRefreshError,
|
shouldClearRefreshError,
|
||||||
|
isNewerDatabaseStatusSequence,
|
||||||
resolveStoredCatalogIndex
|
resolveStoredCatalogIndex
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
|
|||||||
- 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단
|
- 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단
|
||||||
- 과거 기본 경로 증거: 승인된 PGM 고정 runner에서 `5001 → 5006 → TAKE OUT`과 Network Monitoring `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE`, 39개 연속 캡처를 검증했다. 이는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh 또는 35개 builder 동등성 완료 증거가 아니다.
|
- 과거 기본 경로 증거: 승인된 PGM 고정 runner에서 `5001 → 5006 → TAKE OUT`과 Network Monitoring `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE`, 39개 연속 캡처를 검증했다. 이는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh 또는 35개 builder 동등성 완료 증거가 아니다.
|
||||||
- 완료: 첫 과거 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지
|
- 완료: 첫 과거 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지
|
||||||
- 현재 목표 미완료: 설치된 MSIX WebView workflow의 실제 Tornado2 `PREPARE → fresh TAKE IN → playlist/Page NEXT → timer refresh → TAKE OUT`, Network Monitoring과 PGM 동시 검증은 새 회차 승인 전이며 아직 실행하지 않았다.
|
- 현재 목표 미완료: 승인 회차 `MBNWEB-20260711-A`에서 설치된 MSIX WebView의 CONNECT와 `PREPARE 5001/page 1` 및 Network Monitoring transaction/mutation/SCENE_PREPARE는 성공했다. 그러나 fresh TAKE IN의 Oracle 조회가 K3D PLAY 전에 `DATABASE_UNAVAILABLE`로 명확히 실패해 재시도하지 않았고, STOPAL/UNLOAD/BYE로 안전하게 회수했다. 회차 종료 뒤 Oracle/MariaDB 전체 스모크는 다시 통과했지만 새 회차에서 `fresh TAKE IN → playlist/Page NEXT → timer refresh → TAKE OUT`과 PGM 동시 검증을 통과해야 한다.
|
||||||
- 완료: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload와 pending callback fail-closed accounting
|
- 완료: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload와 pending callback fail-closed accounting
|
||||||
- 완료: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB 검증
|
- 완료: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB 검증
|
||||||
|
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`
|
|||||||
|
|
||||||
On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다.
|
On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다.
|
||||||
|
|
||||||
승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 남은 단계는 회차 승인을 받은 실제 Tornado2 Network Monitoring/PGM 검증입니다.
|
승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 첫 Live WebView 회차 `MBNWEB-20260711-A`는 CONNECT와 PREPARE까지 성공했지만 fresh TAKE IN의 Oracle 조회가 PLAY 전에 명확히 실패해 STOPAL/UNLOAD/BYE로 안전하게 종료했습니다. 회차 종료 뒤 Oracle/MariaDB 전체 스모크는 다시 통과했습니다. 남은 단계는 새 package와 회차 승인을 받아 실제 TAKE IN, timer refresh, playlist/Page NEXT, TAKE OUT을 Network Monitoring과 PGM에서 함께 통과하는 것입니다.
|
||||||
|
|
||||||
일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다.
|
일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
| 항목 | 상태 |
|
| 항목 | 상태 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 기본 모드 | `DryRun`; COM 객체와 `KTAPConnect`를 만들지 않음 |
|
| 기본 모드 | `DryRun`; COM 객체와 `KTAPConnect`를 만들지 않음 |
|
||||||
| 자동 테스트 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 64/64, Web safety 11/11 통과; 이후 테스트 추가 시 개수보다 실패 0건을 기준으로 재확인 |
|
| 자동 테스트 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 65/65, Web safety 12/12 통과; 이후 테스트 추가 시 개수보다 실패 0건을 기준으로 재확인 |
|
||||||
| Visual Studio 2026 | Debug/Release x64 빌드 성공 |
|
| Visual Studio 2026 | Debug/Release x64 빌드 성공 |
|
||||||
| trusted Release x64 MSIX | 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 5001 timer refresh, 5074 playlist/Page NEXT와 1~20페이지 마지막 경계, TAKE OUT 정리를 확인 |
|
| trusted Release x64 MSIX | 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 5001 timer refresh, 5074 playlist/Page NEXT와 1~20페이지 마지막 경계, TAKE OUT 정리를 확인 |
|
||||||
| 실제 데이터 전체 scene smoke | 34개 도달 가능 builder 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic 포함 Oracle/MariaDB query 55건 통과 |
|
| 실제 데이터 전체 scene smoke | 34개 도달 가능 builder 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic 포함 Oracle/MariaDB query 55건 통과 |
|
||||||
| `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight |
|
| `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight |
|
||||||
| `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 |
|
| `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 |
|
||||||
| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **미승인·미실행** |
|
| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **부분 실행·실패 회차 종료; 새 회차 필요** |
|
||||||
|
|
||||||
과거 고정 `5001 → 5006` runner의 실제 PGM 기록은 연결과 기본 K3D 명령 표면의 증거다. 현재 WebView playlist, fresh TAKE IN, Page NEXT, timer refresh, callback/unload의 동등성 완료 증거는 아니다. 이번 WebView runtime의 실제 Tornado2 Network Monitoring/PGM 회차는 여전히 승인되지 않았고 실행하지 않았다.
|
과거 고정 `5001 → 5006` runner의 실제 PGM 기록은 연결과 기본 K3D 명령 표면의 증거다. 현재 WebView playlist, fresh TAKE IN, Page NEXT, timer refresh, callback/unload의 동등성 완료 증거는 아니다.
|
||||||
|
|
||||||
|
2026-07-11의 승인 회차 `MBNWEB-20260711-A`에서는 현재 MSIX WebView runtime으로 `CONNECT → PREPARE 5001/page 1`까지 성공했다. Network Monitoring에 `HELLO`, `LOAD_SCENE`, `BEGIN_TRANSACTION`, 데이터 mutation, `QUERY_VARIABLES`, `END_TRANSACTION`, `SCENE_PREPARE`의 request/success가 기록됐고 PREPARE 동안 PGM은 검은 화면이었다. TAKE IN은 두 번째 K3D Prepare/Play 전에 수행하는 fresh Oracle 조회에서 `DATABASE_UNAVAILABLE`로 명확히 실패했다. `PLAY`가 없고 PGM도 검은 상태임을 확인해 TAKE IN을 반복하지 않았으며, 승인 범위의 `TAKE OUT All` 한 번으로 `STOPAL → UNLOAD_SCENE 5001` 성공을 확인하고 `BYE` 뒤 앱을 정상 종료했다. 직후 probe는 Oracle unhealthy/MariaDB healthy와 정상 DNS/TCP 도달을 보였고, 회차 종료 뒤 전체 55-query 스모크는 두 DB 모두 다시 통과했다. 이 회차는 실패·복구 증거이며 동등성 완료 증거가 아니다. 새 package/계획 SHA-256과 새 Gate A/B로 처음부터 다시 시작한다.
|
||||||
|
|
||||||
|
이 회차 뒤 health query와 시장 탭 DB 조회를 송출 fresh query와 하나의 DB activity gate로 직렬화했다. 송출 명령과 자동 on-air refresh가 시작되면 진행 중인 health/market 조회를 취소하고 gate를 먼저 확보하며, 30초 periodic health는 송출 중 건너뛴다. native database status에는 단조 증가 sequence를 붙이고 Web은 오래된 응답을 폐기한다. DB health의 `IsTransient`와 숫자 provider error code만 진단 도구에 보존하며 SQL, parameter, host, 계정, 암호, connection string과 provider message는 출력하지 않는다.
|
||||||
|
|
||||||
## 절대 안전 규칙
|
## 절대 안전 규칙
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,16 @@
|
|||||||
| 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 |
|
| 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 |
|
||||||
| DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 |
|
| DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 |
|
||||||
| loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 |
|
| loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 |
|
||||||
| 자동 테스트 | 완료 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 64/64, Web safety 11/11 통과. 테스트 추가에 따라 개수는 달라질 수 있으며 핵심 판정은 각 suite의 실패 0건이다. |
|
| 자동 테스트 | 완료 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 65/65, Web safety 12/12 통과. 테스트 추가에 따라 개수는 달라질 수 있으며 핵심 판정은 각 suite의 실패 0건이다. |
|
||||||
| Visual Studio 2026 빌드 | 완료 | Debug/Release x64 빌드 성공 |
|
| Visual Studio 2026 빌드 | 완료 | Debug/Release x64 빌드 성공 |
|
||||||
| Release x64 MSIX | 완료 | trusted 개발 MSIX 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 `5001 PREPARE → fresh TAKE IN → timer refresh → 5074 playlist NEXT → Page NEXT → TAKE OUT`과 5074의 `1/20`~`20/20`, 마지막 `END OF PLAYLIST`/NEXT 비활성, 편집 잠금 해제를 확인 |
|
| Release x64 MSIX | 완료 | trusted 개발 MSIX 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 `5001 PREPARE → fresh TAKE IN → timer refresh → 5074 playlist NEXT → Page NEXT → TAKE OUT`과 5074의 `1/20`~`20/20`, 마지막 `END OF PLAYLIST`/NEXT 비활성, 편집 잠금 해제를 확인 |
|
||||||
| 실제 데이터 전체 장면 smoke | 완료 | 34개 도달 가능 builder 전체 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55건이 모두 성공했다. |
|
| 실제 데이터 전체 장면 smoke | 완료 | 34개 도달 가능 builder 전체 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55건이 모두 성공했다. |
|
||||||
| 이번 마이그레이션의 실제 Tornado2/PGM | **미승인·미실행** | 현재 WebView → native workflow로 실제 PREPARE/fresh TAKE IN/Page NEXT/playlist NEXT/timer refresh/TAKE OUT, Network Monitoring, PGM 화면을 함께 검증하지 않음 |
|
| 이번 마이그레이션의 실제 Tornado2/PGM | **부분 실행·실패 회차 종료** | `MBNWEB-20260711-A`에서 CONNECT와 `PREPARE 5001/page 1`은 성공했으나 fresh TAKE IN의 Oracle 조회가 `DATABASE_UNAVAILABLE`로 명확히 실패했다. K3D `PLAY`는 발생하지 않았고 Page/playlist NEXT와 timer refresh는 실행하지 않음 |
|
||||||
|
|
||||||
과거 고정 runner로 수행한 `5001 → 5006` PGM 왕복은 K3D 연결과 기본 load/play/stop 경로의 증거일 뿐이다. 35개 builder, 실제 DB mutation, Page NEXT와 현재 WebView workflow의 동등성 증거로 재사용하지 않는다.
|
과거 고정 runner로 수행한 `5001 → 5006` PGM 왕복은 K3D 연결과 기본 load/play/stop 경로의 증거일 뿐이다. 35개 builder, 실제 DB mutation, Page NEXT와 현재 WebView workflow의 동등성 증거로 재사용하지 않는다.
|
||||||
|
|
||||||
|
`MBNWEB-20260711-A`는 장애 복구 절차의 실제 증거다. Network Monitoring에서 `HELLO`, 5001의 `LOAD_SCENE`/transaction/mutation/`SCENE_PREPARE` 성공을 확인했고 PREPARE 뒤 PGM은 검은 화면을 유지했다. TAKE IN은 fresh Oracle 조회 단계에서 명확히 거부되어 `PLAY`가 없었으며 재시도하지 않았다. 승인된 `TAKE OUT All` 한 번으로 `STOPAL`과 `UNLOAD_SCENE 5001` 성공을 확인한 뒤 `BYE`로 종료했고 PGM은 계속 검은 화면이었다. 직후 독립 DB probe에서는 Oracle unhealthy/MariaDB healthy였고 Oracle DNS와 TCP endpoint는 도달 가능했으며, 회차 종료 뒤 전체 55-query 스모크는 Oracle/MariaDB 모두 다시 통과했다. 이 실패 회차는 동등성 완료 증거가 아니므로 새 동결 계획·새 회차 승인 뒤 전체 시퀀스를 다시 검증해야 한다.
|
||||||
|
|
||||||
## 표기
|
## 표기
|
||||||
|
|
||||||
Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM-neutral 모델을 뜻한다.
|
Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM-neutral 모델을 뜻한다.
|
||||||
@@ -165,4 +167,4 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원
|
|||||||
3. 같은 회차의 Tornado2 Network Monitoring 명령/응답과 PGM 데이터·페이지·종료 화면을 함께 보존하고 비교해야 한다.
|
3. 같은 회차의 Tornado2 Network Monitoring 명령/응답과 PGM 데이터·페이지·종료 화면을 함께 보존하고 비교해야 한다.
|
||||||
4. timeout, `OutcomeUnknown`, refresh fault, callback/연결 장애 복구 절차를 실제 검증 결과에 적용하고 운영자가 판정해야 한다.
|
4. timeout, `OutcomeUnknown`, refresh fault, callback/연결 장애 복구 절차를 실제 검증 결과에 적용하고 운영자가 판정해야 한다.
|
||||||
|
|
||||||
실제 DB/CP949 통합 검증과 자동 suite는 완료됐지만, 위 실제 Tornado2 검증은 여전히 회차 승인 전이며 실행하지 않았다.
|
실제 DB/CP949 통합 검증과 자동 suite는 완료됐고 회차 종료 뒤 DB 스모크도 다시 통과했다. 다만 첫 WebView Live 회차는 fresh TAKE IN 전 Oracle 장애로 안전하게 종료됐으므로, 새 회차에서 TAKE IN → timer refresh → playlist/Page NEXT → TAKE OUT 전체를 통과하기 전까지 동등성 완료로 판정하지 않는다.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ public sealed record DatabaseHealthStatus(
|
|||||||
DatabaseHealthState State,
|
DatabaseHealthState State,
|
||||||
DateTimeOffset CheckedAt,
|
DateTimeOffset CheckedAt,
|
||||||
TimeSpan Elapsed,
|
TimeSpan Elapsed,
|
||||||
string UserMessage);
|
string UserMessage,
|
||||||
|
bool? IsTransient = null,
|
||||||
|
int? ProviderErrorCode = null);
|
||||||
|
|
||||||
public interface IDatabaseHealthService
|
public interface IDatabaseHealthService
|
||||||
{
|
{
|
||||||
@@ -51,7 +53,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService
|
|||||||
DatabaseHealthState.NotConfigured,
|
DatabaseHealthState.NotConfigured,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
TimeSpan.Zero,
|
TimeSpan.Zero,
|
||||||
"연결 정보가 설정되지 않았습니다.");
|
"연결 정보가 설정되지 않았습니다.",
|
||||||
|
IsTransient: null,
|
||||||
|
ProviderErrorCode: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
@@ -73,7 +77,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService
|
|||||||
DatabaseHealthState.Healthy,
|
DatabaseHealthState.Healthy,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
"연결 정상");
|
"연결 정상",
|
||||||
|
IsTransient: null,
|
||||||
|
ProviderErrorCode: null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -86,7 +92,20 @@ public sealed class DatabaseHealthService : IDatabaseHealthService
|
|||||||
DatabaseHealthState.TimedOut,
|
DatabaseHealthState.TimedOut,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
exception.Message);
|
exception.Message,
|
||||||
|
exception.IsTransient,
|
||||||
|
exception.ProviderErrorCode);
|
||||||
|
}
|
||||||
|
catch (DatabaseOperationException exception)
|
||||||
|
{
|
||||||
|
return new DatabaseHealthStatus(
|
||||||
|
source,
|
||||||
|
DatabaseHealthState.Unhealthy,
|
||||||
|
checkedAt,
|
||||||
|
stopwatch.Elapsed,
|
||||||
|
exception.Message,
|
||||||
|
exception.IsTransient,
|
||||||
|
exception.ProviderErrorCode);
|
||||||
}
|
}
|
||||||
catch (DatabaseInfrastructureException exception)
|
catch (DatabaseInfrastructureException exception)
|
||||||
{
|
{
|
||||||
@@ -95,7 +114,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService
|
|||||||
DatabaseHealthState.Unhealthy,
|
DatabaseHealthState.Unhealthy,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
exception.Message);
|
exception.Message,
|
||||||
|
IsTransient: null,
|
||||||
|
ProviderErrorCode: null);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -104,7 +125,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService
|
|||||||
DatabaseHealthState.Unhealthy,
|
DatabaseHealthState.Unhealthy,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
"데이터베이스 연결 상태를 확인할 수 없습니다.");
|
"데이터베이스 연결 상태를 확인할 수 없습니다.",
|
||||||
|
IsTransient: null,
|
||||||
|
ProviderErrorCode: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class DatabaseHealthServiceTests
|
|||||||
Assert.Equal(DatabaseHealthState.NotConfigured, status.State);
|
Assert.Equal(DatabaseHealthState.NotConfigured, status.State);
|
||||||
Assert.Equal(DataSourceKind.Oracle, status.Source);
|
Assert.Equal(DataSourceKind.Oracle, status.Source);
|
||||||
Assert.Equal(TimeSpan.Zero, status.Elapsed);
|
Assert.Equal(TimeSpan.Zero, status.Elapsed);
|
||||||
|
Assert.Null(status.IsTransient);
|
||||||
|
Assert.Null(status.ProviderErrorCode);
|
||||||
Assert.Equal(0, executor.CallCount);
|
Assert.Equal(0, executor.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ public sealed class DatabaseHealthServiceTests
|
|||||||
|
|
||||||
Assert.Equal(DatabaseHealthState.Healthy, status.State);
|
Assert.Equal(DatabaseHealthState.Healthy, status.State);
|
||||||
Assert.Equal(source, status.Source);
|
Assert.Equal(source, status.Source);
|
||||||
|
Assert.Null(status.IsTransient);
|
||||||
|
Assert.Null(status.ProviderErrorCode);
|
||||||
Assert.Equal(1, executor.CallCount);
|
Assert.Equal(1, executor.CallCount);
|
||||||
Assert.Equal(expectedQuery, executor.LastQuery);
|
Assert.Equal(expectedQuery, executor.LastQuery);
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,10 @@ public sealed class DatabaseHealthServiceTests
|
|||||||
var executor = new StubDataQueryExecutor
|
var executor = new StubDataQueryExecutor
|
||||||
{
|
{
|
||||||
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
|
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
|
||||||
new DatabaseQueryException(DataSourceKind.MariaDb, isTransient: true))
|
new DatabaseQueryException(
|
||||||
|
DataSourceKind.MariaDb,
|
||||||
|
isTransient: true,
|
||||||
|
providerErrorCode: 1042))
|
||||||
};
|
};
|
||||||
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
|
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
|
||||||
|
|
||||||
@@ -56,6 +63,8 @@ public sealed class DatabaseHealthServiceTests
|
|||||||
|
|
||||||
Assert.Equal(DatabaseHealthState.Unhealthy, status.State);
|
Assert.Equal(DatabaseHealthState.Unhealthy, status.State);
|
||||||
Assert.Equal(DataSourceKind.MariaDb, status.Source);
|
Assert.Equal(DataSourceKind.MariaDb, status.Source);
|
||||||
|
Assert.True(status.IsTransient);
|
||||||
|
Assert.Equal(1042, status.ProviderErrorCode);
|
||||||
Assert.DoesNotContain(host, status.UserMessage, StringComparison.Ordinal);
|
Assert.DoesNotContain(host, status.UserMessage, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +82,25 @@ public sealed class DatabaseHealthServiceTests
|
|||||||
|
|
||||||
Assert.Equal(DatabaseHealthState.TimedOut, status.State);
|
Assert.Equal(DatabaseHealthState.TimedOut, status.State);
|
||||||
Assert.Equal(DataSourceKind.Oracle, status.Source);
|
Assert.Equal(DataSourceKind.Oracle, status.Source);
|
||||||
|
Assert.True(status.IsTransient);
|
||||||
|
Assert.Null(status.ProviderErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckAsync_NonOperationInfrastructureFailure_DoesNotInventDiagnostics()
|
||||||
|
{
|
||||||
|
var executor = new StubDataQueryExecutor
|
||||||
|
{
|
||||||
|
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
|
||||||
|
new DatabaseConfigurationException("safe configuration failure"))
|
||||||
|
};
|
||||||
|
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
|
||||||
|
|
||||||
|
var status = await service.CheckAsync(DataSourceKind.Oracle);
|
||||||
|
|
||||||
|
Assert.Equal(DatabaseHealthState.Unhealthy, status.State);
|
||||||
|
Assert.Null(status.IsTransient);
|
||||||
|
Assert.Null(status.ProviderErrorCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ test("A database refresh fault clears only after TAKE OUT resets refresh state",
|
|||||||
assert.equal(safety.shouldClearRefreshError(unrelated, false), false);
|
assert.equal(safety.shouldClearRefreshError(unrelated, false), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Database health snapshots accept only a strictly increasing native sequence", () => {
|
||||||
|
assert.equal(safety.isNewerDatabaseStatusSequence(0, 1), true);
|
||||||
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, 8), true);
|
||||||
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, 7), false);
|
||||||
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, 6), false);
|
||||||
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, "8"), false);
|
||||||
|
assert.equal(safety.isNewerDatabaseStatusSequence(7, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("Stored conditional aliases preserve the exact builder owner", () => {
|
test("Stored conditional aliases preserve the exact builder owner", () => {
|
||||||
const catalog = [
|
const catalog = [
|
||||||
{ builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"] },
|
{ builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"] },
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ try
|
|||||||
|
|
||||||
foreach (var status in statuses)
|
foreach (var status in statuses)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(FormatHealthStatus(status));
|
||||||
$"{status.Source}: {status.State} ({status.Elapsed.TotalMilliseconds:F0} ms) - {status.UserMessage}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statuses.Any(status => status.State != DatabaseHealthState.Healthy))
|
if (statuses.Any(status => status.State != DatabaseHealthState.Healthy))
|
||||||
@@ -49,9 +48,20 @@ catch (OperationCanceledException)
|
|||||||
Console.Error.WriteLine("REAL_DB_SMOKE: CANCELED_OR_TIMED_OUT");
|
Console.Error.WriteLine("REAL_DB_SMOKE: CANCELED_OR_TIMED_OUT");
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
catch (DatabaseInfrastructureException exception)
|
catch (DatabaseOperationException exception)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}");
|
Console.Error.WriteLine(
|
||||||
|
"REAL_DB_SMOKE: FAIL - " +
|
||||||
|
$"Source={exception.DataSource} " +
|
||||||
|
$"IsTransient={FormatNullableBoolean(exception.IsTransient)} " +
|
||||||
|
$"ProviderErrorCode={FormatProviderErrorCode(exception.ProviderErrorCode)}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
catch (DatabaseInfrastructureException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"REAL_DB_SMOKE: FAIL - A database infrastructure error occurred. " +
|
||||||
|
"No provider details were printed.");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -61,6 +71,32 @@ catch
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string FormatHealthStatus(DatabaseHealthStatus status)
|
||||||
|
{
|
||||||
|
var summary =
|
||||||
|
$"{status.Source}: {status.State} " +
|
||||||
|
$"({status.Elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture)} ms)";
|
||||||
|
|
||||||
|
if (status.State == DatabaseHealthState.Healthy)
|
||||||
|
{
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary +
|
||||||
|
$" IsTransient={FormatNullableBoolean(status.IsTransient)}" +
|
||||||
|
$" ProviderErrorCode={FormatProviderErrorCode(status.ProviderErrorCode)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string FormatNullableBoolean(bool? value) => value switch
|
||||||
|
{
|
||||||
|
true => "true",
|
||||||
|
false => "false",
|
||||||
|
null => "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
static string FormatProviderErrorCode(int? value) =>
|
||||||
|
value?.ToString(CultureInfo.InvariantCulture) ?? "none";
|
||||||
|
|
||||||
static string? GetConfigurationPath(string[] arguments)
|
static string? GetConfigurationPath(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length == 0)
|
if (arguments.Length == 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user