fix: serialize database activity during playout
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user