1809 lines
64 KiB
C#
1809 lines
64 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 IStockMasterIdentityValidationService? _stockMasterIdentityValidationService;
|
|
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);
|
|
_stockMasterIdentityValidationService =
|
|
new LegacyStockMasterIdentityValidationService(runtime.Executor);
|
|
_operatorCatalogSchemaValidationService =
|
|
new LegacyOperatorCatalogSchemaValidationService(runtime.Executor);
|
|
}
|
|
catch
|
|
{
|
|
_themeCatalogPersistenceService = null;
|
|
_expertCatalogPersistenceService = null;
|
|
_stockMasterIdentityValidationService = 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;
|
|
var identityValidation = _stockMasterIdentityValidationService;
|
|
await ExecuteOperatorCatalogWriteAsync(
|
|
requestId,
|
|
"theme",
|
|
"create",
|
|
definition.ThemeCode,
|
|
service?.CanMutate == true && identityValidation is not null,
|
|
async cancellationToken =>
|
|
{
|
|
_ = await identityValidation!.ValidateThemeItemsAsync(
|
|
definition.Items,
|
|
cancellationToken);
|
|
},
|
|
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;
|
|
var identityValidation = _stockMasterIdentityValidationService;
|
|
await ExecuteOperatorCatalogWriteAsync(
|
|
requestId,
|
|
"theme",
|
|
"replace",
|
|
expectedIdentity.ThemeCode,
|
|
service?.CanMutate == true && identityValidation is not null,
|
|
async cancellationToken =>
|
|
{
|
|
_ = await identityValidation!.ValidateThemeItemsAsync(
|
|
items,
|
|
cancellationToken);
|
|
},
|
|
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,
|
|
validationBody: null,
|
|
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;
|
|
var identityValidation = _stockMasterIdentityValidationService;
|
|
await ExecuteOperatorCatalogWriteAsync(
|
|
requestId,
|
|
"expert",
|
|
"create",
|
|
definition.Identity.ExpertCode,
|
|
service?.CanMutate == true && identityValidation is not null,
|
|
async cancellationToken =>
|
|
{
|
|
_ = await identityValidation!.ValidateExpertRecommendationsAsync(
|
|
definition.Recommendations,
|
|
cancellationToken);
|
|
},
|
|
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;
|
|
var identityValidation = _stockMasterIdentityValidationService;
|
|
await ExecuteOperatorCatalogWriteAsync(
|
|
requestId,
|
|
"expert",
|
|
"replace",
|
|
expectedIdentity.ExpertCode,
|
|
service?.CanMutate == true && identityValidation is not null,
|
|
async cancellationToken =>
|
|
{
|
|
_ = await identityValidation!.ValidateExpertRecommendationsAsync(
|
|
recommendations,
|
|
cancellationToken);
|
|
},
|
|
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,
|
|
validationBody: null,
|
|
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>? validationBody,
|
|
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;
|
|
}
|
|
|
|
if (validationBody is not null)
|
|
{
|
|
await validationBody(requestToken);
|
|
requestToken.ThrowIfCancellationRequested();
|
|
if (!CanStartOperatorCatalogWrite())
|
|
{
|
|
PostOperatorCatalogPlayoutActive(
|
|
requestId,
|
|
entity,
|
|
operation,
|
|
identityCode);
|
|
return;
|
|
}
|
|
|
|
if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) ||
|
|
!ReferenceEquals(
|
|
Volatile.Read(ref _operatorCatalogWriteCancellation),
|
|
requestCancellation))
|
|
{
|
|
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 (StockMasterIdentityDataException) when (!mutationDispatched)
|
|
{
|
|
PostOperatorCatalogWriteErrorIfCurrent(
|
|
requestId,
|
|
entity,
|
|
operation,
|
|
identityCode,
|
|
"INVALID_DATA",
|
|
"Every catalog stock must be selected from one current, unambiguous stock-master identity.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (DatabaseOperationException) when (!mutationDispatched)
|
|
{
|
|
PostOperatorCatalogWriteErrorIfCurrent(
|
|
requestId,
|
|
entity,
|
|
operation,
|
|
identityCode,
|
|
"DATABASE_ERROR",
|
|
"The stock-master identity verification failed before the database transaction began.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
|
}
|
|
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 &&
|
|
_stockMasterIdentityValidationService is not null &&
|
|
!IsOperatorCatalogWriteQuarantined();
|
|
|
|
private bool CanMutateExpertCatalog() =>
|
|
_expertCatalogPersistenceService?.CanMutate == true &&
|
|
_stockMasterIdentityValidationService is not null &&
|
|
!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;
|
|
_stockMasterIdentityValidationService = 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) ||
|
|
!TryGetExistingThemeCatalogCode(element, "themeCode", 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 TryGetExistingThemeCatalogCode(
|
|
JsonElement payload,
|
|
string propertyName,
|
|
out string value)
|
|
{
|
|
value = GetString(payload, propertyName) ?? string.Empty;
|
|
return value.Length is >= 3 and <= 8 && 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;
|
|
}
|
|
}
|