Files
MBN_STOCK_WEBVIEW/MainWindow.OperatorCatalogs.cs

1713 lines
60 KiB
C#

using System.Globalization;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private const string OperatorCatalogQuarantineMessage =
"A theme/expert database write result could not be confirmed. Restart the app and reconcile the database before another catalog write.";
private const decimal OperatorCatalogMaximumWebSafeInteger = 9_007_199_254_740_991m;
private readonly SemaphoreSlim _operatorCatalogWriteGate = new(1, 1);
private readonly object _operatorCatalogReadLanesGate = new();
private readonly Dictionary<string, OperatorCatalogReadRequest> _operatorCatalogReadLanes =
new(StringComparer.Ordinal);
private IThemeCatalogPersistenceService? _themeCatalogPersistenceService;
private IExpertCatalogPersistenceService? _expertCatalogPersistenceService;
private IOperatorCatalogSchemaValidationService? _operatorCatalogSchemaValidationService;
private CancellationTokenSource? _operatorCatalogWriteCancellation;
private string? _operatorCatalogInitializationError;
private string? _operatorCatalogWriteQuarantineMessage;
private int _operatorCatalogRuntimeState;
private int _operatorCatalogWriteInFlight;
private int _operatorCatalogWriteQuarantined;
private int _operatorCatalogBrowserGeneration;
/// <summary>
/// Root integration hook. It is safe to call after InitializeDatabaseRuntime;
/// request handlers also call it lazily so a missing optional eager call fails
/// closed rather than dereferencing an uninitialized service.
/// </summary>
private void InitializeOperatorCatalogRuntime()
{
if (Volatile.Read(ref _operatorCatalogRuntimeState) != 0)
{
return;
}
var runtime = _databaseRuntime;
if (runtime is null ||
Interlocked.CompareExchange(ref _operatorCatalogRuntimeState, 1, 0) != 0)
{
return;
}
try
{
var mutationExecutor = new OperatorCatalogMutationExecutor(
runtime.ConnectionFactory,
runtime.Options.Resilience);
_themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService(
runtime.Executor,
mutationExecutor);
_expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService(
runtime.Executor,
mutationExecutor);
_operatorCatalogSchemaValidationService =
new LegacyOperatorCatalogSchemaValidationService(runtime.Executor);
}
catch
{
_themeCatalogPersistenceService = null;
_expertCatalogPersistenceService = null;
_operatorCatalogSchemaValidationService = null;
_operatorCatalogInitializationError =
"The theme/expert database boundary could not be initialized.";
Volatile.Write(ref _operatorCatalogRuntimeState, -1);
}
}
/// <summary>
/// Root WebMessage switch hook. Return true means the request type belongs to
/// this boundary, including malformed payloads which receive a correlated
/// INVALID_REQUEST response here.
/// </summary>
private bool TryHandleOperatorCatalogRequest(string? requestType, JsonElement payload)
{
switch (requestType)
{
case "request-theme-catalog-list":
_ = HandleThemeCatalogListRequestAsync(payload);
return true;
case "request-expert-catalog-list":
_ = HandleExpertCatalogListRequestAsync(payload);
return true;
case "request-operator-catalog-schema":
_ = HandleOperatorCatalogSchemaRequestAsync(payload);
return true;
case "request-theme-catalog-next-code":
_ = HandleThemeCatalogNextCodeRequestAsync(payload);
return true;
case "request-expert-catalog-next-code":
_ = HandleExpertCatalogNextCodeRequestAsync(payload);
return true;
case "create-theme-catalog":
_ = HandleThemeCatalogCreateRequestAsync(payload);
return true;
case "replace-theme-catalog":
_ = HandleThemeCatalogReplaceRequestAsync(payload);
return true;
case "delete-theme-catalog":
_ = HandleThemeCatalogDeleteRequestAsync(payload);
return true;
case "create-expert-catalog":
_ = HandleExpertCatalogCreateRequestAsync(payload);
return true;
case "replace-expert-catalog":
_ = HandleExpertCatalogReplaceRequestAsync(payload);
return true;
case "delete-expert-catalog":
_ = HandleExpertCatalogDeleteRequestAsync(payload);
return true;
default:
return false;
}
}
private async Task HandleThemeCatalogListRequestAsync(JsonElement payload)
{
if (!TryParseThemeCatalogListRequest(
payload,
out var requestId,
out var query,
out var nxtSession,
out var maximumResults))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"theme",
"list");
return;
}
InitializeOperatorCatalogRuntime();
var service = _themeSelectionService;
if (service is null)
{
PostOperatorCatalogUnavailable(requestId, "theme", "list");
return;
}
await ExecuteOperatorCatalogReadAsync(
requestId,
"theme",
"list",
async cancellationToken =>
{
var result = await service.SearchAsync(
query,
nxtSession,
maximumResults,
cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return new OperatorCatalogBridgeReply(
"theme-catalog-list-results",
new
{
requestId,
query = result.Query,
nxtSession = ToWireValue(result.NxtSession),
result.RetrievedAt,
totalRowCount = result.Items.Count,
truncated = result.IsTruncated,
canMutate = CanMutateThemeCatalog(),
writeQuarantined = IsOperatorCatalogWriteQuarantined(),
results = result.Items.Select(item => new
{
market = ToWireValue(item.Identity.Market),
session = ToWireValue(item.Identity.Session),
themeCode = item.Identity.ThemeCode,
themeTitle = item.Identity.ThemeTitle,
source = ToWireValue(item.Source)
}).ToArray()
});
});
}
private async Task HandleExpertCatalogListRequestAsync(JsonElement payload)
{
if (!TryParseExpertCatalogListRequest(
payload,
out var requestId,
out var query,
out var maximumResults))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"expert",
"list");
return;
}
InitializeOperatorCatalogRuntime();
var service = _expertSelectionService;
if (service is null)
{
PostOperatorCatalogUnavailable(requestId, "expert", "list");
return;
}
await ExecuteOperatorCatalogReadAsync(
requestId,
"expert",
"list",
async cancellationToken =>
{
var result = await service.SearchAsync(
query,
maximumResults,
cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return new OperatorCatalogBridgeReply(
"expert-catalog-list-results",
new
{
requestId,
query = result.Query,
result.RetrievedAt,
totalRowCount = result.Items.Count,
truncated = result.IsTruncated,
canMutate = CanMutateExpertCatalog(),
writeQuarantined = IsOperatorCatalogWriteQuarantined(),
results = result.Items.Select(item => new
{
expertCode = item.Identity.ExpertCode,
expertName = item.Identity.ExpertName,
source = ToWireValue(item.Source)
}).ToArray()
});
});
}
private async Task HandleOperatorCatalogSchemaRequestAsync(JsonElement payload)
{
if (!TryParseOperatorCatalogRequestIdOnly(payload, out var requestId))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"catalog",
"schemaPreflight");
return;
}
InitializeOperatorCatalogRuntime();
var service = _operatorCatalogSchemaValidationService;
if (service is null)
{
PostOperatorCatalogUnavailable(
requestId,
"catalog",
"schemaPreflight");
return;
}
await ExecuteOperatorCatalogReadAsync(
requestId,
"catalog",
"schemaPreflight",
async cancellationToken =>
{
var result = await service.ValidateAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return new OperatorCatalogBridgeReply(
"operator-catalog-schema-results",
new
{
requestId,
result.ValidatedAt,
validatedSources = result.ValidatedSources
.Select(ToWireValue)
.ToArray(),
themeCanMutate = CanMutateThemeCatalog(),
expertCanMutate = CanMutateExpertCatalog(),
writeQuarantined = IsOperatorCatalogWriteQuarantined()
});
});
}
private async Task HandleThemeCatalogNextCodeRequestAsync(JsonElement payload)
{
if (!TryParseThemeCatalogNextCodeRequest(
payload,
out var requestId,
out var market))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"theme",
"nextCode");
return;
}
InitializeOperatorCatalogRuntime();
var service = _themeCatalogPersistenceService;
if (service is null)
{
PostOperatorCatalogUnavailable(requestId, "theme", "nextCode");
return;
}
await ExecuteOperatorCatalogReadAsync(
requestId,
"theme",
"nextCode",
async cancellationToken =>
{
var code = await service.SuggestNextCodeAsync(market, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return new OperatorCatalogBridgeReply(
"theme-catalog-next-code-results",
new
{
requestId,
market = ToWireValue(market),
themeCode = code,
canMutate = CanMutateThemeCatalog(),
writeQuarantined = IsOperatorCatalogWriteQuarantined()
});
});
}
private async Task HandleExpertCatalogNextCodeRequestAsync(JsonElement payload)
{
if (!TryParseOperatorCatalogRequestIdOnly(payload, out var requestId))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"expert",
"nextCode");
return;
}
InitializeOperatorCatalogRuntime();
var service = _expertCatalogPersistenceService;
if (service is null)
{
PostOperatorCatalogUnavailable(requestId, "expert", "nextCode");
return;
}
await ExecuteOperatorCatalogReadAsync(
requestId,
"expert",
"nextCode",
async cancellationToken =>
{
var code = await service.SuggestNextCodeAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return new OperatorCatalogBridgeReply(
"expert-catalog-next-code-results",
new
{
requestId,
expertCode = code,
canMutate = CanMutateExpertCatalog(),
writeQuarantined = IsOperatorCatalogWriteQuarantined()
});
});
}
private async Task HandleThemeCatalogCreateRequestAsync(JsonElement payload)
{
if (!TryParseThemeCatalogCreateRequest(
payload,
out var requestId,
out var definition))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"theme",
"create");
return;
}
InitializeOperatorCatalogRuntime();
var service = _themeCatalogPersistenceService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"theme",
"create",
definition.ThemeCode,
service?.CanMutate == true,
cancellationToken => service!.CreateAsync(definition, cancellationToken));
}
private async Task HandleThemeCatalogReplaceRequestAsync(JsonElement payload)
{
if (!TryParseThemeCatalogReplaceRequest(
payload,
out var requestId,
out var expectedIdentity,
out var newTitle,
out var items))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"theme",
"replace");
return;
}
InitializeOperatorCatalogRuntime();
var service = _themeCatalogPersistenceService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"theme",
"replace",
expectedIdentity.ThemeCode,
service?.CanMutate == true,
cancellationToken => service!.ReplaceAsync(
expectedIdentity,
newTitle,
items,
cancellationToken));
}
private async Task HandleThemeCatalogDeleteRequestAsync(JsonElement payload)
{
if (!TryParseThemeCatalogDeleteRequest(
payload,
out var requestId,
out var identity))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"theme",
"delete");
return;
}
InitializeOperatorCatalogRuntime();
var service = _themeCatalogPersistenceService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"theme",
"delete",
identity.ThemeCode,
service?.CanMutate == true,
cancellationToken => service!.DeleteAsync(identity, cancellationToken));
}
private async Task HandleExpertCatalogCreateRequestAsync(JsonElement payload)
{
if (!TryParseExpertCatalogCreateRequest(
payload,
out var requestId,
out var definition))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"expert",
"create");
return;
}
InitializeOperatorCatalogRuntime();
var service = _expertCatalogPersistenceService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"expert",
"create",
definition.Identity.ExpertCode,
service?.CanMutate == true,
cancellationToken => service!.CreateAsync(definition, cancellationToken));
}
private async Task HandleExpertCatalogReplaceRequestAsync(JsonElement payload)
{
if (!TryParseExpertCatalogReplaceRequest(
payload,
out var requestId,
out var expectedIdentity,
out var newName,
out var recommendations))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"expert",
"replace");
return;
}
InitializeOperatorCatalogRuntime();
var service = _expertCatalogPersistenceService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"expert",
"replace",
expectedIdentity.ExpertCode,
service?.CanMutate == true,
cancellationToken => service!.ReplaceAsync(
expectedIdentity,
newName,
recommendations,
cancellationToken));
}
private async Task HandleExpertCatalogDeleteRequestAsync(JsonElement payload)
{
if (!TryParseExpertCatalogDeleteRequest(
payload,
out var requestId,
out var identity))
{
PostOperatorCatalogInvalidRequest(
SafeOperatorCatalogRequestId(payload),
"expert",
"delete");
return;
}
InitializeOperatorCatalogRuntime();
var service = _expertCatalogPersistenceService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"expert",
"delete",
identity.ExpertCode,
service?.CanMutate == true,
cancellationToken => service!.DeleteAsync(identity, cancellationToken));
}
private async Task ExecuteOperatorCatalogReadAsync(
string requestId,
string entity,
string operation,
Func<CancellationToken, Task<OperatorCatalogBridgeReply>> operationBody)
{
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
var requestToken = requestCancellation.Token;
var browserGeneration = Volatile.Read(ref _operatorCatalogBrowserGeneration);
var request = new OperatorCatalogReadRequest(
$"{entity}:{operation}",
requestId,
entity,
operation,
browserGeneration,
requestCancellation);
RegisterOperatorCatalogRead(request);
var databaseActivityEntered = false;
try
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
requestCancellation.Cancel();
PostOperatorCatalogReadCancellationIfCurrent(
request,
"PLAYOUT_PRIORITY",
"The catalog read was canceled because playout has database priority.");
return;
}
await _databaseActivityGate.WaitAsync(requestToken);
databaseActivityEntered = true;
requestToken.ThrowIfCancellationRequested();
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
requestCancellation.Cancel();
PostOperatorCatalogReadCancellationIfCurrent(
request,
"PLAYOUT_PRIORITY",
"The catalog read was canceled because playout has database priority.");
return;
}
if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration))
{
requestCancellation.Cancel();
return;
}
var reply = await operationBody(requestToken);
requestToken.ThrowIfCancellationRequested();
if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) ||
!TryCompleteOperatorCatalogReadIfCurrent(request))
{
return;
}
PostMessage(reply.MessageType, reply.Payload);
}
catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
{
PostOperatorCatalogReadCancellationIfCurrent(
request,
"PLAYOUT_PRIORITY",
"The catalog read was canceled because playout has database priority.");
}
catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
{
PostOperatorCatalogReadCancellationIfCurrent(
request,
"PLAYOUT_PRIORITY",
"The catalog read was canceled because playout has database priority.");
}
catch (Exception) when (requestToken.IsCancellationRequested)
{
PostOperatorCatalogReadCancellationIfCurrent(
request,
"PLAYOUT_PRIORITY",
"The catalog read was canceled because playout has database priority.");
}
catch (ArgumentException)
{
PostOperatorCatalogReadErrorIfCurrent(
request,
"INVALID_REQUEST",
"The theme/expert catalog request is invalid.",
retryable: false);
}
catch (ThemeSelectionDataException)
{
PostOperatorCatalogReadErrorIfCurrent(
request,
"INVALID_DATA",
"Theme catalog data is ambiguous or invalid.",
retryable: false);
}
catch (ExpertSelectionDataException)
{
PostOperatorCatalogReadErrorIfCurrent(
request,
"INVALID_DATA",
"Expert catalog data is ambiguous or invalid.",
retryable: false);
}
catch (OperatorCatalogSchemaException)
{
PostOperatorCatalogReadErrorIfCurrent(
request,
"INVALID_SCHEMA",
"The theme/expert database schema does not match the migrated contract.",
retryable: false);
}
catch (DatabaseOperationException exception)
{
PostOperatorCatalogReadErrorIfCurrent(
request,
"DATABASE_ERROR",
exception.Message,
retryable: true);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch
{
PostOperatorCatalogReadErrorIfCurrent(
request,
"DATABASE_ERROR",
"The theme/expert database read failed.",
retryable: true);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
finally
{
if (databaseActivityEntered)
{
_databaseActivityGate.Release();
}
ReleaseOperatorCatalogRead(request);
requestCancellation.Dispose();
}
}
private async Task ExecuteOperatorCatalogWriteAsync(
string requestId,
string entity,
string operation,
string identityCode,
bool canMutate,
Func<CancellationToken, Task<OperatorCatalogMutationReceipt>> operationBody)
{
if (!canMutate)
{
PostOperatorCatalogUnavailable(requestId, entity, operation, identityCode);
return;
}
if (IsOperatorCatalogWriteQuarantined())
{
PostOperatorCatalogQuarantined(requestId, entity, operation, identityCode);
return;
}
if (!await _operatorCatalogWriteGate.WaitAsync(0))
{
PostOperatorCatalogError(
requestId,
entity,
operation,
identityCode,
"WRITE_BUSY",
"Another theme/expert write is already running.",
retryable: false,
outcomeUnknown: false);
return;
}
var playoutGateEntered = false;
var databaseActivityEntered = false;
var mutationDispatched = false;
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
var requestToken = requestCancellation.Token;
var browserGeneration = Volatile.Read(ref _operatorCatalogBrowserGeneration);
Volatile.Write(ref _operatorCatalogWriteCancellation, requestCancellation);
try
{
playoutGateEntered = await _playoutCommandGate.WaitAsync(0, requestToken);
if (!playoutGateEntered)
{
PostOperatorCatalogPlayoutActive(
requestId,
entity,
operation,
identityCode);
return;
}
if (!CanStartOperatorCatalogWrite())
{
PostOperatorCatalogPlayoutActive(
requestId,
entity,
operation,
identityCode);
return;
}
databaseActivityEntered = await _databaseActivityGate.WaitAsync(0, requestToken);
if (!databaseActivityEntered)
{
PostOperatorCatalogError(
requestId,
entity,
operation,
identityCode,
"DATABASE_BUSY",
"The database is busy. Refresh before making a new explicit write request.",
retryable: false,
outcomeUnknown: false);
return;
}
requestToken.ThrowIfCancellationRequested();
if (!CanStartOperatorCatalogWrite())
{
PostOperatorCatalogPlayoutActive(
requestId,
entity,
operation,
identityCode);
return;
}
if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration))
{
requestCancellation.Cancel();
return;
}
mutationDispatched = true;
Interlocked.Exchange(ref _operatorCatalogWriteInFlight, 1);
var receipt = await operationBody(requestToken);
if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) ||
!ReferenceEquals(
Volatile.Read(ref _operatorCatalogWriteCancellation),
requestCancellation))
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
return;
}
PostMessage("operator-catalog-mutation-results", new
{
requestId,
entity,
operation,
identityCode,
operationId = receipt.OperationId,
receipt.CommittedAt,
writeQuarantined = IsOperatorCatalogWriteQuarantined()
});
}
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
{
if (mutationDispatched)
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
}
}
catch (OperatorCatalogConflictException)
{
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"CONFLICT",
"The catalog changed or a code/name is already in use. Refresh before a new edit.",
outcomeUnknown: false,
requestCancellation);
}
catch (OperatorCatalogMutationException exception)
{
if (exception.OutcomeUnknown)
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"OUTCOME_UNKNOWN",
OperatorCatalogQuarantineMessage,
outcomeUnknown: true,
requestCancellation);
}
else
{
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"WRITE_FAILED",
"The database transaction rolled back and was not retried.",
outcomeUnknown: false,
requestCancellation);
}
}
catch (ArgumentException)
{
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"INVALID_REQUEST",
"The theme/expert write request is invalid.",
outcomeUnknown: false,
requestCancellation);
}
catch (OperationCanceledException)
{
if (mutationDispatched)
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"OUTCOME_UNKNOWN",
OperatorCatalogQuarantineMessage,
outcomeUnknown: true,
requestCancellation);
}
else
{
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"CANCELLED",
"The write stopped before a database transaction began.",
outcomeUnknown: false,
requestCancellation);
}
}
catch
{
if (mutationDispatched)
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
}
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
mutationDispatched ? "OUTCOME_UNKNOWN" : "WRITE_FAILED",
mutationDispatched
? OperatorCatalogQuarantineMessage
: "The write failed before a database transaction began.",
outcomeUnknown: mutationDispatched,
requestCancellation);
}
finally
{
Interlocked.Exchange(ref _operatorCatalogWriteInFlight, 0);
if (databaseActivityEntered)
{
_databaseActivityGate.Release();
}
if (playoutGateEntered)
{
_playoutCommandGate.Release();
}
Interlocked.CompareExchange(
ref _operatorCatalogWriteCancellation,
null,
requestCancellation);
requestCancellation.Dispose();
_operatorCatalogWriteGate.Release();
}
}
private bool CanStartOperatorCatalogWrite()
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0 ||
Volatile.Read(ref _playoutShutdownStarted) != 0 ||
IsBrowserCorrelationQuarantined() ||
IsOperatorCatalogWriteQuarantined())
{
return false;
}
return CanStartOperatorMutation(IsOperatorCatalogWriteQuarantined());
}
private void RegisterOperatorCatalogRead(OperatorCatalogReadRequest request)
{
OperatorCatalogReadRequest? superseded;
var shouldPostSuperseded = false;
lock (_operatorCatalogReadLanesGate)
{
_operatorCatalogReadLanes.TryGetValue(request.Lane, out superseded);
_operatorCatalogReadLanes[request.Lane] = request;
shouldPostSuperseded = superseded?.TryComplete() == true;
}
if (superseded is null)
{
return;
}
CancelRequest(superseded.Cancellation);
if (shouldPostSuperseded)
{
PostOperatorCatalogError(
superseded.RequestId,
superseded.Entity,
superseded.Operation,
string.Empty,
"CANCELLED",
"The catalog read was canceled because a newer request replaced it.",
retryable: true,
outcomeUnknown: false);
}
}
private bool TryCompleteOperatorCatalogReadIfCurrent(OperatorCatalogReadRequest request)
{
lock (_operatorCatalogReadLanesGate)
{
return request.BrowserGeneration ==
Volatile.Read(ref _operatorCatalogBrowserGeneration) &&
_operatorCatalogReadLanes.TryGetValue(request.Lane, out var current) &&
ReferenceEquals(current, request) &&
request.TryComplete();
}
}
private void ReleaseOperatorCatalogRead(OperatorCatalogReadRequest request)
{
lock (_operatorCatalogReadLanesGate)
{
if (_operatorCatalogReadLanes.TryGetValue(request.Lane, out var current) &&
ReferenceEquals(current, request))
{
_operatorCatalogReadLanes.Remove(request.Lane);
}
}
}
private OperatorCatalogReadRequest[] SnapshotOperatorCatalogReads(bool clear)
{
lock (_operatorCatalogReadLanesGate)
{
var requests = _operatorCatalogReadLanes.Values.ToArray();
if (clear)
{
_operatorCatalogReadLanes.Clear();
foreach (var request in requests)
{
_ = request.TryComplete();
}
}
return requests;
}
}
private void PostOperatorCatalogReadCancellationIfCurrent(
OperatorCatalogReadRequest request,
string code,
string message) =>
PostOperatorCatalogReadErrorIfCurrent(
request,
code,
message,
retryable: true);
private void PostOperatorCatalogReadErrorIfCurrent(
OperatorCatalogReadRequest request,
string code,
string message,
bool retryable)
{
if (_lifetimeCancellation.IsCancellationRequested ||
!TryCompleteOperatorCatalogReadIfCurrent(request))
{
return;
}
PostOperatorCatalogError(
request.RequestId,
request.Entity,
request.Operation,
string.Empty,
code,
message,
retryable,
outcomeUnknown: false);
}
private void PostOperatorCatalogWriteErrorIfCurrent(
string requestId,
string entity,
string operation,
string identityCode,
string code,
string message,
bool outcomeUnknown,
CancellationTokenSource requestCancellation)
{
if (_lifetimeCancellation.IsCancellationRequested ||
!ReferenceEquals(
Volatile.Read(ref _operatorCatalogWriteCancellation),
requestCancellation))
{
return;
}
PostOperatorCatalogError(
requestId,
entity,
operation,
identityCode,
code,
message,
retryable: false,
outcomeUnknown);
}
private void PostOperatorCatalogInvalidRequest(
string requestId,
string entity,
string operation) =>
PostOperatorCatalogError(
requestId,
entity,
operation,
string.Empty,
"INVALID_REQUEST",
"The theme/expert catalog request is invalid.",
retryable: false,
outcomeUnknown: false);
private void PostOperatorCatalogUnavailable(
string requestId,
string entity,
string operation,
string identityCode = "") =>
PostOperatorCatalogError(
requestId,
entity,
operation,
identityCode,
"DATABASE_UNAVAILABLE",
_operatorCatalogInitializationError ??
_databaseInitializationError ??
"The theme/expert database boundary is unavailable.",
retryable: false,
outcomeUnknown: false);
private void PostOperatorCatalogPlayoutActive(
string requestId,
string entity,
string operation,
string identityCode) =>
PostOperatorCatalogError(
requestId,
entity,
operation,
identityCode,
"PLAYOUT_ACTIVE",
"Theme/expert database writes are allowed only while playout is fully IDLE.",
retryable: false,
outcomeUnknown: false);
private void PostOperatorCatalogQuarantined(
string requestId,
string entity,
string operation,
string identityCode) =>
PostOperatorCatalogError(
requestId,
entity,
operation,
identityCode,
"OUTCOME_UNKNOWN",
GetOperatorMutationQuarantineMessage(
Volatile.Read(ref _operatorCatalogWriteQuarantineMessage) ??
OperatorCatalogQuarantineMessage),
retryable: false,
outcomeUnknown: true);
private void PostOperatorCatalogError(
string requestId,
string entity,
string operation,
string identityCode,
string code,
string message,
bool retryable,
bool outcomeUnknown)
{
PostMessage("operator-catalog-error", new
{
requestId,
entity,
operation,
identityCode,
code,
message,
retryable = retryable && !outcomeUnknown,
outcomeUnknown,
writeQuarantined = IsOperatorCatalogWriteQuarantined()
});
}
private bool CanMutateThemeCatalog() =>
_themeCatalogPersistenceService?.CanMutate == true &&
!IsOperatorCatalogWriteQuarantined();
private bool CanMutateExpertCatalog() =>
_expertCatalogPersistenceService?.CanMutate == true &&
!IsOperatorCatalogWriteQuarantined();
private bool IsOperatorCatalogWriteQuarantined() =>
Volatile.Read(ref _operatorCatalogWriteQuarantined) != 0 ||
IsOperatorMutationQuarantined();
private void LatchOperatorCatalogWriteQuarantine(string message)
{
LatchOperatorMutationQuarantine(message);
if (Interlocked.CompareExchange(ref _operatorCatalogWriteQuarantined, 1, 0) == 0)
{
Volatile.Write(
ref _operatorCatalogWriteQuarantineMessage,
string.IsNullOrWhiteSpace(message)
? OperatorCatalogQuarantineMessage
: message);
}
}
/// <summary>
/// Root browser-navigation/process-failure hook. Losing JavaScript request
/// correlation while a write is active permanently quarantines writes for
/// this process and never retries the command.
/// </summary>
private void InvalidateOperatorCatalogBrowserRequests()
{
Interlocked.Increment(ref _operatorCatalogBrowserGeneration);
if (Volatile.Read(ref _operatorCatalogWriteInFlight) != 0)
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
}
foreach (var request in SnapshotOperatorCatalogReads(clear: true))
{
CancelRequest(request.Cancellation);
}
CancelRequest(Interlocked.Exchange(ref _operatorCatalogWriteCancellation, null));
}
/// <summary>
/// Root playout-priority hook. A catalog write owns the playout gate and
/// therefore cannot reach this point concurrently; only read work is canceled.
/// </summary>
private void CancelOperatorCatalogReadsForPlayout()
{
foreach (var request in SnapshotOperatorCatalogReads(clear: false))
{
CancelRequest(request.Cancellation);
}
}
/// <summary>Root window-close hook.</summary>
private void ShutdownOperatorCatalogRuntime()
{
if (Volatile.Read(ref _operatorCatalogWriteInFlight) != 0)
{
LatchOperatorCatalogWriteQuarantine(OperatorCatalogQuarantineMessage);
}
foreach (var request in SnapshotOperatorCatalogReads(clear: true))
{
CancelRequest(request.Cancellation);
}
CancelRequest(Interlocked.Exchange(ref _operatorCatalogWriteCancellation, null));
_themeCatalogPersistenceService = null;
_expertCatalogPersistenceService = null;
_operatorCatalogSchemaValidationService = null;
}
private static bool TryParseThemeCatalogListRequest(
JsonElement payload,
out string requestId,
out string query,
out ThemeSession nxtSession,
out int maximumResults)
{
requestId = string.Empty;
query = string.Empty;
nxtSession = default;
maximumResults = LegacyThemeSelectionService.DefaultMaximumResults;
return payload.ValueKind == JsonValueKind.Object &&
HasOnlyProperties(payload, "requestId", "query", "nxtSession", "maximumResults") &&
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
TryGetOperatorCatalogQuery(
payload,
"query",
LegacyThemeSelectionService.MaximumQueryLength,
out query) &&
TryParseThemeNxtSession(GetString(payload, "nxtSession"), out nxtSession) &&
TryGetOptionalOperatorCatalogLimit(
payload,
LegacyThemeSelectionService.MaximumResults,
ref maximumResults);
}
private static bool TryParseExpertCatalogListRequest(
JsonElement payload,
out string requestId,
out string query,
out int maximumResults)
{
requestId = string.Empty;
query = string.Empty;
maximumResults = LegacyExpertSelectionService.DefaultMaximumResults;
return payload.ValueKind == JsonValueKind.Object &&
HasOnlyProperties(payload, "requestId", "query", "maximumResults") &&
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
TryGetOperatorCatalogQuery(
payload,
"query",
LegacyExpertSelectionService.MaximumQueryLength,
out query) &&
TryGetOptionalOperatorCatalogLimit(
payload,
LegacyExpertSelectionService.MaximumResults,
ref maximumResults);
}
private static bool TryParseOperatorCatalogRequestIdOnly(
JsonElement payload,
out string requestId)
{
requestId = string.Empty;
return payload.ValueKind == JsonValueKind.Object &&
HasOnlyProperties(payload, "requestId") &&
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId);
}
private static bool TryParseThemeCatalogNextCodeRequest(
JsonElement payload,
out string requestId,
out ThemeMarket market)
{
requestId = string.Empty;
market = default;
return payload.ValueKind == JsonValueKind.Object &&
HasOnlyProperties(payload, "requestId", "market") &&
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
TryParseThemeMarket(GetString(payload, "market"), out market);
}
private static bool TryParseThemeCatalogCreateRequest(
JsonElement payload,
out string requestId,
out ThemeCatalogDefinition definition)
{
requestId = string.Empty;
definition = null!;
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId", "draft") ||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
!payload.TryGetProperty("draft", out var draft) ||
draft.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(draft, "market", "themeCode", "themeTitle", "items") ||
!TryParseThemeMarket(GetString(draft, "market"), out var market) ||
!TryGetOperatorCatalogCode(draft, "themeCode", 8, out var code) ||
!TryGetOperatorCatalogText(draft, "themeTitle", 128, out var title) ||
market == ThemeMarket.Nxt && title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase) ||
!TryParseThemeCatalogItems(draft, market, out var items))
{
return false;
}
definition = new ThemeCatalogDefinition(market, code, title, items);
return true;
}
private static bool TryParseThemeCatalogReplaceRequest(
JsonElement payload,
out string requestId,
out ThemeSelectionIdentity expectedIdentity,
out string newTitle,
out IReadOnlyList<ThemeCatalogItem> items)
{
requestId = string.Empty;
expectedIdentity = null!;
newTitle = string.Empty;
items = Array.Empty<ThemeCatalogItem>();
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId", "expectedIdentity", "newTitle", "items") ||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
!payload.TryGetProperty("expectedIdentity", out var identityElement) ||
!TryParseThemeCatalogIdentity(identityElement, out expectedIdentity) ||
!TryGetOperatorCatalogText(payload, "newTitle", 128, out newTitle) ||
expectedIdentity.Market == ThemeMarket.Nxt &&
newTitle.Contains("(NXT)", StringComparison.OrdinalIgnoreCase) ||
!TryParseThemeCatalogItems(payload, expectedIdentity.Market, out items))
{
return false;
}
return true;
}
private static bool TryParseThemeCatalogDeleteRequest(
JsonElement payload,
out string requestId,
out ThemeSelectionIdentity identity)
{
requestId = string.Empty;
identity = null!;
return payload.ValueKind == JsonValueKind.Object &&
HasOnlyProperties(payload, "requestId", "identity") &&
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
payload.TryGetProperty("identity", out var identityElement) &&
TryParseThemeCatalogIdentity(identityElement, out identity);
}
private static bool TryParseThemeCatalogIdentity(
JsonElement element,
out ThemeSelectionIdentity identity)
{
identity = null!;
if (element.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(element, "market", "session", "themeCode", "themeTitle") ||
!TryParseThemeMarket(GetString(element, "market"), out var market) ||
!TryParseThemeSession(GetString(element, "session"), out var session) ||
market == ThemeMarket.Krx && session != ThemeSession.NotApplicable ||
market == ThemeMarket.Nxt &&
session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket) ||
!TryGetOperatorCatalogCode(element, "themeCode", 8, out var code) ||
!TryGetOperatorCatalogText(element, "themeTitle", 128, out var title) ||
market == ThemeMarket.Nxt && title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase))
{
return false;
}
identity = new ThemeSelectionIdentity(market, session, code, title);
return true;
}
private static bool TryParseThemeCatalogItems(
JsonElement parent,
ThemeMarket market,
out IReadOnlyList<ThemeCatalogItem> items)
{
items = Array.Empty<ThemeCatalogItem>();
if (!parent.TryGetProperty("items", out var array) ||
array.ValueKind != JsonValueKind.Array ||
array.GetArrayLength() > LegacyThemeCatalogPersistenceService.MaximumItems)
{
return false;
}
var parsed = new List<ThemeCatalogItem>(array.GetArrayLength());
var codes = new HashSet<string>(StringComparer.Ordinal);
var names = new HashSet<string>(StringComparer.Ordinal);
var expectedIndex = 0;
foreach (var element in array.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(element, "inputIndex", "itemCode", "itemName") ||
!element.TryGetProperty("inputIndex", out var indexElement) ||
indexElement.ValueKind != JsonValueKind.Number ||
!indexElement.TryGetInt32(out var inputIndex) ||
inputIndex != expectedIndex ||
!TryGetOperatorCatalogText(element, "itemCode", 13, out var itemCode) ||
!IsThemeItemCodeForMarket(itemCode, market) ||
!TryGetOperatorCatalogText(element, "itemName", 200, out var itemName) ||
!codes.Add(itemCode) ||
!names.Add(itemName))
{
return false;
}
parsed.Add(new ThemeCatalogItem(inputIndex, itemCode, itemName));
expectedIndex++;
}
items = parsed.AsReadOnly();
return true;
}
private static bool TryParseExpertCatalogCreateRequest(
JsonElement payload,
out string requestId,
out ExpertCatalogDefinition definition)
{
requestId = string.Empty;
definition = null!;
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId", "draft") ||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
!payload.TryGetProperty("draft", out var draft) ||
draft.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(draft, "expertCode", "expertName", "recommendations") ||
!TryGetOperatorCatalogCode(draft, "expertCode", 4, out var code) ||
!TryGetOperatorCatalogText(draft, "expertName", 128, out var name) ||
!TryParseExpertCatalogRecommendations(draft, out var recommendations))
{
return false;
}
definition = new ExpertCatalogDefinition(
new ExpertSelectionIdentity(code, name),
recommendations);
return true;
}
private static bool TryParseExpertCatalogReplaceRequest(
JsonElement payload,
out string requestId,
out ExpertSelectionIdentity expectedIdentity,
out string newName,
out IReadOnlyList<ExpertCatalogRecommendation> recommendations)
{
requestId = string.Empty;
expectedIdentity = null!;
newName = string.Empty;
recommendations = Array.Empty<ExpertCatalogRecommendation>();
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(
payload,
"requestId",
"expectedIdentity",
"newName",
"recommendations") ||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
!payload.TryGetProperty("expectedIdentity", out var identityElement) ||
!TryParseExpertCatalogIdentity(identityElement, out expectedIdentity) ||
!TryGetOperatorCatalogText(payload, "newName", 128, out newName) ||
!TryParseExpertCatalogRecommendations(payload, out recommendations))
{
return false;
}
return true;
}
private static bool TryParseExpertCatalogDeleteRequest(
JsonElement payload,
out string requestId,
out ExpertSelectionIdentity identity)
{
requestId = string.Empty;
identity = null!;
return payload.ValueKind == JsonValueKind.Object &&
HasOnlyProperties(payload, "requestId", "identity") &&
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
payload.TryGetProperty("identity", out var identityElement) &&
TryParseExpertCatalogIdentity(identityElement, out identity);
}
private static bool TryParseExpertCatalogIdentity(
JsonElement element,
out ExpertSelectionIdentity identity)
{
identity = null!;
if (element.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(element, "expertCode", "expertName") ||
!TryGetOperatorCatalogCode(element, "expertCode", 4, out var code) ||
!TryGetOperatorCatalogText(element, "expertName", 128, out var name))
{
return false;
}
identity = new ExpertSelectionIdentity(code, name);
return true;
}
private static bool TryParseExpertCatalogRecommendations(
JsonElement parent,
out IReadOnlyList<ExpertCatalogRecommendation> recommendations)
{
recommendations = Array.Empty<ExpertCatalogRecommendation>();
if (!parent.TryGetProperty("recommendations", out var array) ||
array.ValueKind != JsonValueKind.Array ||
array.GetArrayLength() > LegacyExpertCatalogPersistenceService.MaximumRecommendations)
{
return false;
}
var parsed = new List<ExpertCatalogRecommendation>(array.GetArrayLength());
var codes = new HashSet<string>(StringComparer.Ordinal);
var names = new HashSet<string>(StringComparer.Ordinal);
var expectedIndex = 0;
foreach (var element in array.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(
element,
"playIndex",
"stockCode",
"stockName",
"buyAmount") ||
!element.TryGetProperty("playIndex", out var indexElement) ||
indexElement.ValueKind != JsonValueKind.Number ||
!indexElement.TryGetInt32(out var playIndex) ||
playIndex != expectedIndex ||
!TryGetOperatorCatalogText(element, "stockCode", 32, out var stockCode) ||
!stockCode.All(char.IsAsciiLetterOrDigit) ||
!TryGetOperatorCatalogText(element, "stockName", 200, out var stockName) ||
!element.TryGetProperty("buyAmount", out var amountElement) ||
amountElement.ValueKind != JsonValueKind.Number ||
!amountElement.TryGetInt64(out var amount) ||
amount <= 0 ||
amount > OperatorCatalogMaximumWebSafeInteger ||
!codes.Add(stockCode) ||
!names.Add(stockName))
{
return false;
}
parsed.Add(new ExpertCatalogRecommendation(
playIndex,
stockCode,
stockName,
amount));
expectedIndex++;
}
recommendations = parsed.AsReadOnly();
return true;
}
private static bool TryGetOptionalOperatorCatalogLimit(
JsonElement payload,
int maximum,
ref int value)
{
if (!payload.TryGetProperty("maximumResults", out var element))
{
return true;
}
return element.ValueKind == JsonValueKind.Number &&
element.TryGetInt32(out value) &&
value is >= 1 && value <= maximum;
}
private static bool TryGetOperatorCatalogQuery(
JsonElement payload,
string propertyName,
int maximumLength,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
return payload.TryGetProperty(propertyName, out var element) &&
element.ValueKind == JsonValueKind.String &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeOperatorCatalogText(value);
}
private static bool TryGetOperatorCatalogCode(
JsonElement payload,
string propertyName,
int length,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
return value.Length == length && value.All(char.IsAsciiDigit);
}
private static bool TryGetOperatorCatalogText(
JsonElement payload,
string propertyName,
int maximumLength,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
return payload.TryGetProperty(propertyName, out var element) &&
element.ValueKind == JsonValueKind.String &&
value.Length is > 0 && value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeOperatorCatalogText(value);
}
private static bool IsSafeOperatorCatalogText(string value)
{
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) ||
char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static bool IsThemeItemCodeForMarket(string itemCode, ThemeMarket market)
{
if (itemCode.Length is < 2 or > 13 ||
!itemCode.AsSpan(1).ToArray().All(char.IsAsciiLetterOrDigit))
{
return false;
}
return market switch
{
ThemeMarket.Krx => itemCode[0] is 'P' or 'D',
ThemeMarket.Nxt => itemCode[0] is 'X' or 'T',
_ => false
};
}
private static string SafeOperatorCatalogRequestId(JsonElement payload)
{
return payload.ValueKind == JsonValueKind.Object &&
TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out var requestId)
? requestId
: string.Empty;
}
private readonly record struct OperatorCatalogBridgeReply(string MessageType, object Payload);
private sealed class OperatorCatalogReadRequest
{
private int _terminalState;
public OperatorCatalogReadRequest(
string lane,
string requestId,
string entity,
string operation,
int browserGeneration,
CancellationTokenSource cancellation)
{
Lane = lane;
RequestId = requestId;
Entity = entity;
Operation = operation;
BrowserGeneration = browserGeneration;
Cancellation = cancellation;
}
public string Lane { get; }
public string RequestId { get; }
public string Entity { get; }
public string Operation { get; }
public int BrowserGeneration { get; }
public CancellationTokenSource Cancellation { get; }
public bool TryComplete() => Interlocked.CompareExchange(ref _terminalState, 1, 0) == 0;
}
}