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

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