diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index 92a3361..792537c 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -170,9 +170,14 @@ public sealed partial class MainWindow return; } + var databaseActivityEntered = false; try { Interlocked.Exchange(ref _playoutCommandInFlight, 1); + CancelActiveMarketDataRequest(); + CancelActiveDatabaseHealthCheck(); + await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token); + databaseActivityEntered = true; if (IsBrowserCorrelationQuarantined()) { PostPlayoutCommandError( @@ -308,6 +313,10 @@ public sealed partial class MainWindow finally { Interlocked.Exchange(ref _playoutCommandInFlight, 0); + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } _playoutCommandGate.Release(); // A browser-side timeout may have discarded the correlated result while the // native operation was still completing. Always publish the authoritative @@ -760,6 +769,7 @@ public sealed partial class MainWindow { await Task.Delay(nextDelay, token); await _playoutCommandGate.WaitAsync(token); + var databaseActivityEntered = false; try { if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) || @@ -769,6 +779,10 @@ public sealed partial class MainWindow } Interlocked.Exchange(ref _playoutCommandInFlight, 1); + CancelActiveMarketDataRequest(); + CancelActiveDatabaseHealthCheck(); + await _databaseActivityGate.WaitAsync(token); + databaseActivityEntered = true; var result = await workflow.RefreshOnAirAsync(token); // A failed, timed-out, or unknown update never repeats. The operator // must reconcile native/PGM state before starting another command. @@ -819,6 +833,10 @@ public sealed partial class MainWindow finally { Interlocked.Exchange(ref _playoutCommandInFlight, 0); + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } _playoutCommandGate.Release(); QueuePlayoutStatus(); } diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index c330865..a2b861c 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -18,9 +18,12 @@ public sealed partial class MainWindow : Window private const string AppHost = "app.mbn.local"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly SemaphoreSlim _databaseActivityGate = new(1, 1); private DatabaseRuntime? _databaseRuntime; private IMarketDataService? _marketDataService; private CancellationTokenSource? _marketDataCancellation; + private CancellationTokenSource? _databaseHealthCancellation; + private long _databaseStatusSequence; private string? _databaseInitializationError; private bool _webViewReady; @@ -273,10 +276,29 @@ public sealed partial class MainWindow : Window previousCancellation?.Cancel(); previousCancellation?.Dispose(); + var databaseActivityEntered = false; 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 .GetAsync(view, maximumRows, requestCancellation.Token); + requestCancellation.Token.ThrowIfCancellationRequested(); PostMessage("market-data", new { @@ -309,6 +331,11 @@ public sealed partial class MainWindow : Window } finally { + if (databaseActivityEntered) + { + _databaseActivityGate.Release(); + } + if (ReferenceEquals(Interlocked.CompareExchange( ref _marketDataCancellation, 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) { 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) { var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."; - PostMessage("database-status", new + PostDatabaseStatus(new[] { - sources = new[] - { - CreateUnavailableStatus("oracle", message), - CreateUnavailableStatus("mariaDb", message) - } + CreateUnavailableStatus("oracle", message), + CreateUnavailableStatus("mariaDb", message) }); return; } @@ -355,41 +468,71 @@ public sealed partial class MainWindow : Window var statuses = await _databaseRuntime.HealthService .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), - healthy = status.State == DatabaseHealthState.Healthy, - state = status.State.ToString().ToLowerInvariant(), - message = status.UserMessage, - checkedAt = status.CheckedAt, - latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds) - }) - }); + source = ToWireValue(status.Source), + configured = _databaseRuntime.Options.IsConfigured(status.Source), + healthy = status.State == DatabaseHealthState.Healthy, + state = status.State.ToString().ToLowerInvariant(), + message = status.UserMessage, + checkedAt = status.CheckedAt, + latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds) + })); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + throw; } 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) { while (!cancellationToken.IsCancellationRequested) { - await PostDatabaseStatusAsync(cancellationToken); + await PostDatabaseStatusAsync(cancellationToken, periodic: true); try { @@ -538,6 +681,7 @@ public sealed partial class MainWindow : Window var wasWebViewReady = _webViewReady; _webViewReady = false; _lifetimeCancellation.Cancel(); + CancelActiveDatabaseHealthCheck(); ShutdownPlayoutRuntime(); var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null); requestCancellation?.Cancel(); diff --git a/README.md b/README.md index aea62bb..605420f 100644 --- a/README.md +++ b/README.md @@ -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)를 참고하세요. +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에서 실행 1. `MBN_STOCK_WEBVIEW.sln`을 엽니다. diff --git a/Web/app.js b/Web/app.js index da5e067..bb19dff 100644 --- a/Web/app.js +++ b/Web/app.js @@ -173,7 +173,7 @@ error: null, lastSignature: "" }, - database: { sources: [], loading: true, lastSignature: "" }, + database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 }, liveData: { requestId: null, view: null, @@ -1400,6 +1400,12 @@ } 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.loading = false; renderDatabaseStatus(); diff --git a/Web/playout-safety.js b/Web/playout-safety.js index 21541ff..9e91ac0 100644 --- a/Web/playout-safety.js +++ b/Web/playout-safety.js @@ -69,6 +69,14 @@ 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) { if (!Array.isArray(catalog)) return -1; const exact = catalog.findIndex(item => @@ -90,6 +98,7 @@ canClearCommandError, playlistSnapshotLocked, shouldClearRefreshError, + isNewerDatabaseStatusSequence, resolveStoredCatalogIndex }; }); diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 83265fb..f1b6670 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -58,7 +58,7 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 - 완료: 네이티브/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 동등성 완료 증거가 아니다. - 완료: 첫 과거 회차의 출력 전 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 - 완료: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB 검증 diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 0fa94ae..9b6e91f 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -345,7 +345,7 @@ WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting` 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한 채 운영자가 실제 출력 상태를 먼저 확인합니다. diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md index 1766d62..c42a26e 100644 --- a/docs/PLAYOUT_OPERATIONS.md +++ b/docs/PLAYOUT_OPERATIONS.md @@ -7,15 +7,19 @@ | 항목 | 상태 | |---|---| | 기본 모드 | `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 빌드 성공 | | 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건 통과 | | `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight | | `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는 출력하지 않는다. ## 절대 안전 규칙 diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md index b5516ba..dac6058 100644 --- a/docs/SCENE_EQUIVALENCE.md +++ b/docs/SCENE_EQUIVALENCE.md @@ -11,14 +11,16 @@ | 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 | | DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 | | 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 빌드 성공 | | 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건이 모두 성공했다. | -| 이번 마이그레이션의 실제 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의 동등성 증거로 재사용하지 않는다. +`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 모델을 뜻한다. @@ -165,4 +167,4 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원 3. 같은 회차의 Tornado2 Network Monitoring 명령/응답과 PGM 데이터·페이지·종료 화면을 함께 보존하고 비교해야 한다. 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 전체를 통과하기 전까지 동등성 완료로 판정하지 않는다. diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Health/DatabaseHealthService.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Health/DatabaseHealthService.cs index 10ec562..1121ae3 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Health/DatabaseHealthService.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Health/DatabaseHealthService.cs @@ -16,7 +16,9 @@ public sealed record DatabaseHealthStatus( DatabaseHealthState State, DateTimeOffset CheckedAt, TimeSpan Elapsed, - string UserMessage); + string UserMessage, + bool? IsTransient = null, + int? ProviderErrorCode = null); public interface IDatabaseHealthService { @@ -51,7 +53,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService DatabaseHealthState.NotConfigured, checkedAt, TimeSpan.Zero, - "연결 정보가 설정되지 않았습니다."); + "연결 정보가 설정되지 않았습니다.", + IsTransient: null, + ProviderErrorCode: null); } var stopwatch = Stopwatch.StartNew(); @@ -73,7 +77,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService DatabaseHealthState.Healthy, checkedAt, stopwatch.Elapsed, - "연결 정상"); + "연결 정상", + IsTransient: null, + ProviderErrorCode: null); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -86,7 +92,20 @@ public sealed class DatabaseHealthService : IDatabaseHealthService DatabaseHealthState.TimedOut, checkedAt, 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) { @@ -95,7 +114,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService DatabaseHealthState.Unhealthy, checkedAt, stopwatch.Elapsed, - exception.Message); + exception.Message, + IsTransient: null, + ProviderErrorCode: null); } catch { @@ -104,7 +125,9 @@ public sealed class DatabaseHealthService : IDatabaseHealthService DatabaseHealthState.Unhealthy, checkedAt, stopwatch.Elapsed, - "데이터베이스 연결 상태를 확인할 수 없습니다."); + "데이터베이스 연결 상태를 확인할 수 없습니다.", + IsTransient: null, + ProviderErrorCode: null); } } diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseHealthServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseHealthServiceTests.cs index b655e3f..4f891a7 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseHealthServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseHealthServiceTests.cs @@ -17,6 +17,8 @@ public sealed class DatabaseHealthServiceTests Assert.Equal(DatabaseHealthState.NotConfigured, status.State); Assert.Equal(DataSourceKind.Oracle, status.Source); Assert.Equal(TimeSpan.Zero, status.Elapsed); + Assert.Null(status.IsTransient); + Assert.Null(status.ProviderErrorCode); Assert.Equal(0, executor.CallCount); } @@ -37,6 +39,8 @@ public sealed class DatabaseHealthServiceTests Assert.Equal(DatabaseHealthState.Healthy, status.State); Assert.Equal(source, status.Source); + Assert.Null(status.IsTransient); + Assert.Null(status.ProviderErrorCode); Assert.Equal(1, executor.CallCount); Assert.Equal(expectedQuery, executor.LastQuery); } @@ -48,7 +52,10 @@ public sealed class DatabaseHealthServiceTests var executor = new StubDataQueryExecutor { ExecuteAsyncHandler = (_, _, _, _) => Task.FromException( - new DatabaseQueryException(DataSourceKind.MariaDb, isTransient: true)) + new DatabaseQueryException( + DataSourceKind.MariaDb, + isTransient: true, + providerErrorCode: 1042)) }; var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured()); @@ -56,6 +63,8 @@ public sealed class DatabaseHealthServiceTests Assert.Equal(DatabaseHealthState.Unhealthy, status.State); Assert.Equal(DataSourceKind.MariaDb, status.Source); + Assert.True(status.IsTransient); + Assert.Equal(1042, status.ProviderErrorCode); Assert.DoesNotContain(host, status.UserMessage, StringComparison.Ordinal); } @@ -73,6 +82,25 @@ public sealed class DatabaseHealthServiceTests Assert.Equal(DatabaseHealthState.TimedOut, status.State); 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( + 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); } } diff --git a/tests/Web/playout-safety.test.cjs b/tests/Web/playout-safety.test.cjs index 8c6f041..3ee5bd3 100644 --- a/tests/Web/playout-safety.test.cjs +++ b/tests/Web/playout-safety.test.cjs @@ -132,6 +132,15 @@ test("A database refresh fault clears only after TAKE OUT resets refresh state", 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", () => { const catalog = [ { builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"] }, diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs index 589ab63..9e00f74 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs @@ -21,8 +21,7 @@ try foreach (var status in statuses) { - Console.WriteLine( - $"{status.Source}: {status.State} ({status.Elapsed.TotalMilliseconds:F0} ms) - {status.UserMessage}"); + Console.WriteLine(FormatHealthStatus(status)); } if (statuses.Any(status => status.State != DatabaseHealthState.Healthy)) @@ -49,9 +48,20 @@ catch (OperationCanceledException) Console.Error.WriteLine("REAL_DB_SMOKE: CANCELED_OR_TIMED_OUT"); 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; } catch @@ -61,6 +71,32 @@ catch 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) { if (arguments.Length == 0)