fix: serialize database activity during playout

This commit is contained in:
2026-07-11 03:43:59 +09:00
parent 8dae7b8e0d
commit f76dbc7272
13 changed files with 327 additions and 46 deletions

View File

@@ -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();
} }

View File

@@ -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();

View File

@@ -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`을 엽니다.

View File

@@ -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();

View File

@@ -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
}; };
}); });

View File

@@ -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 검증

View File

@@ -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 MonitoringPGM에서 함께 통과하는 것입니다.
일반 앱과 정규 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한 채 운영자가 실제 출력 상태를 먼저 확인합니다.

View File

@@ -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는 출력하지 않는다.
## 절대 안전 규칙 ## 절대 안전 규칙

View File

@@ -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 NEXTtimer 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 전체를 통과하기 전까지 동등성 완료로 판정하지 않다.

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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"] },

View File

@@ -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)