From ac0360e9bfe07fc5f52b9f091d7f47bdf25da541 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 8 Dec 2025 11:24:32 +1000 Subject: [PATCH 01/16] Adding noop implementations for Sql persistence --- ...ProjectReferences.Persisters.Primary.props | 1 + .../.editorconfig | 3 + .../NoOpArchiveMessages.cs | 27 +++ .../NoOpBodyStorage.cs | 10 ++ .../NoOpCustomChecksDataStore.cs | 22 +++ .../NoOpEndpointSettingsStore.cs | 20 +++ .../NoOpErrorMessageDataStore.cs | 161 ++++++++++++++++++ .../NoOpEventLogDataStore.cs | 15 ++ ...oOpExternalIntegrationRequestsDataStore.cs | 20 +++ .../NoOpFailedErrorImportDataStore.cs | 16 ++ ...NoOpFailedMessageViewIndexNotifications.cs | 18 ++ .../NoOpGroupsDataStore.cs | 15 ++ .../NoOpMessageRedirectsDataStore.cs | 12 ++ .../NoOpMonitoringDataStore.cs | 25 +++ .../NoOpQueueAddressStore.cs | 19 +++ .../NoOpRetryBatchesDataStore.cs | 85 +++++++++ .../NoOpRetryDocumentDataStore.cs | 41 +++++ .../NoOpRetryHistoryDataStore.cs | 18 ++ .../NoOpServiceControlSubscriptionStorage.cs | 25 +++ .../NoOpTrialLicenseDataProvider.cs | 16 ++ .../ServiceControl.Persistence.Sql.csproj | 20 +++ .../SqlPersistence.cs | 40 +++++ .../persistence.manifest | 7 + src/ServiceControl.sln | 16 ++ 24 files changed, 652 insertions(+) create mode 100644 src/ServiceControl.Persistence.Sql/.editorconfig create mode 100644 src/ServiceControl.Persistence.Sql/NoOpArchiveMessages.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpBodyStorage.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpEndpointSettingsStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpErrorMessageDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpEventLogDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpExternalIntegrationRequestsDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpFailedErrorImportDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpFailedMessageViewIndexNotifications.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpGroupsDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpMessageRedirectsDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpMonitoringDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpQueueAddressStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpRetryBatchesDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpRetryDocumentDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpRetryHistoryDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs create mode 100644 src/ServiceControl.Persistence.Sql/NoOpTrialLicenseDataProvider.cs create mode 100644 src/ServiceControl.Persistence.Sql/ServiceControl.Persistence.Sql.csproj create mode 100644 src/ServiceControl.Persistence.Sql/SqlPersistence.cs create mode 100644 src/ServiceControl.Persistence.Sql/persistence.manifest diff --git a/src/ProjectReferences.Persisters.Primary.props b/src/ProjectReferences.Persisters.Primary.props index 255b45ed5c..0841fa81c2 100644 --- a/src/ProjectReferences.Persisters.Primary.props +++ b/src/ProjectReferences.Persisters.Primary.props @@ -2,6 +2,7 @@ + \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Sql/.editorconfig b/src/ServiceControl.Persistence.Sql/.editorconfig new file mode 100644 index 0000000000..103f58ff80 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.IDE0060.severity = none diff --git a/src/ServiceControl.Persistence.Sql/NoOpArchiveMessages.cs b/src/ServiceControl.Persistence.Sql/NoOpArchiveMessages.cs new file mode 100644 index 0000000000..86bd5c2cec --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpArchiveMessages.cs @@ -0,0 +1,27 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; + +class NoOpArchiveMessages : IArchiveMessages +{ + public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; + + public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; + + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; + + public bool IsArchiveInProgressFor(string groupId) => false; + + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + { + } + + public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + + public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + + public IEnumerable GetArchivalOperations() => []; +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpBodyStorage.cs b/src/ServiceControl.Persistence.Sql/NoOpBodyStorage.cs new file mode 100644 index 0000000000..a04adf2be3 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpBodyStorage.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Threading.Tasks; +using ServiceControl.Operations.BodyStorage; + +class NoOpBodyStorage : IBodyStorage +{ + public Task TryFetch(string bodyId) => + Task.FromResult(new MessageBodyStreamResult { HasResult = false }); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs new file mode 100644 index 0000000000..64ed378310 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.Contracts.CustomChecks; +using ServiceControl.CustomChecks; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; + +class NoOpCustomChecksDataStore : ICustomChecksDataStore +{ + public Task UpdateCustomCheckStatus(CustomCheckDetail detail) => + Task.FromResult(CheckStateChange.Unchanged); + + public Task>> GetStats(PagingInfo paging, string status = null) => + Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task DeleteCustomCheck(Guid id) => Task.CompletedTask; + + public Task GetNumberOfFailedChecks() => Task.FromResult(0); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpEndpointSettingsStore.cs b/src/ServiceControl.Persistence.Sql/NoOpEndpointSettingsStore.cs new file mode 100644 index 0000000000..7ed0c634bc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpEndpointSettingsStore.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +class NoOpEndpointSettingsStore : IEndpointSettingsStore +{ + public async IAsyncEnumerable GetAllEndpointSettings() + { + await Task.CompletedTask; + yield break; + } + + public Task UpdateEndpointSettings(EndpointSettings settings, CancellationToken token) => Task.CompletedTask; + + public Task Delete(string name, CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpErrorMessageDataStore.cs new file mode 100644 index 0000000000..2672518630 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpErrorMessageDataStore.cs @@ -0,0 +1,161 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.CompositeViews.Messages; +using ServiceControl.EventLog; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Notifications; +using ServiceControl.Operations; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +class NoOpErrorMessageDataStore : IErrorMessageDataStore +{ + static readonly QueryResult> EmptyMessagesViewResult = + new([], QueryStatsInfo.Zero); + + static readonly QueryResult> EmptyFailedMessageViewResult = + new([], QueryStatsInfo.Zero); + + static readonly QueryResult> EmptyFailureGroupViewResult = + new([], QueryStatsInfo.Zero); + + static readonly QueryStatsInfo EmptyQueryStatsInfo = QueryStatsInfo.Zero; + + public Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, + bool includeSystemMessages, DateTimeRange timeSentRange = null) => + Task.FromResult(EmptyMessagesViewResult); + + public Task>> GetAllMessagesForEndpoint(string endpointName, + PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange timeSentRange = null) => + Task.FromResult(EmptyMessagesViewResult); + + public Task>> GetAllMessagesByConversation(string conversationId, + PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) => + Task.FromResult(EmptyMessagesViewResult); + + public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, + SortInfo sortInfo, DateTimeRange timeSentRange = null) => + Task.FromResult(EmptyMessagesViewResult); + + public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, + PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null) => + Task.FromResult(EmptyMessagesViewResult); + + public Task FailedMessageMarkAsArchived(string failedMessageId) => Task.CompletedTask; + + public Task FailedMessagesFetch(Guid[] ids) => Task.FromResult(Array.Empty()); + + public Task StoreFailedErrorImport(FailedErrorImport failure) => Task.CompletedTask; + + public Task CreateEditFailedMessageManager() => + Task.FromResult(new NoOpEditFailedMessagesManager()); + + public Task> GetFailureGroupView(string groupId, string status, string modified) => + Task.FromResult(new QueryResult(null, EmptyQueryStatsInfo)); + + public Task> GetFailureGroupsByClassifier(string classifier) => + Task.FromResult>([]); + + public Task>> ErrorGet(string status, string modified, string queueAddress, + PagingInfo pagingInfo, SortInfo sortInfo) => + Task.FromResult(EmptyFailedMessageViewResult); + + public Task ErrorsHead(string status, string modified, string queueAddress) => + Task.FromResult(EmptyQueryStatsInfo); + + public Task>> ErrorsByEndpointName(string status, string endpointName, + string modified, PagingInfo pagingInfo, SortInfo sortInfo) => + Task.FromResult(EmptyFailedMessageViewResult); + + public Task> ErrorsSummary() => + Task.FromResult>(new Dictionary()); + + public Task ErrorLastBy(string failedMessageId) => + Task.FromResult(null); + + public Task ErrorBy(string failedMessageId) => + Task.FromResult(null); + + public Task CreateNotificationsManager() => + Task.FromResult(new NoOpNotificationsManager()); + + public Task EditComment(string groupId, string comment) => Task.CompletedTask; + + public Task DeleteComment(string groupId) => Task.CompletedTask; + + public Task>> GetGroupErrors(string groupId, string status, string modified, + SortInfo sortInfo, PagingInfo pagingInfo) => + Task.FromResult(EmptyFailedMessageViewResult); + + public Task GetGroupErrorsCount(string groupId, string status, string modified) => + Task.FromResult(EmptyQueryStatsInfo); + + public Task>> GetGroup(string groupId, string status, string modified) => + Task.FromResult(EmptyFailureGroupViewResult); + + public Task MarkMessageAsResolved(string failedMessageId) => Task.FromResult(false); + + public Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, string queueAddress, + Func processCallback) => Task.CompletedTask; + + public Task UnArchiveMessagesByRange(DateTime from, DateTime to) => + Task.FromResult(Array.Empty()); + + public Task UnArchiveMessages(IEnumerable failedMessageIds) => + Task.FromResult(Array.Empty()); + + public Task RevertRetry(string messageUniqueId) => Task.CompletedTask; + + public Task RemoveFailedMessageRetryDocument(string uniqueMessageId) => Task.CompletedTask; + + public Task GetRetryPendingMessages(DateTime from, DateTime to, string queueAddress) => + Task.FromResult(Array.Empty()); + + public Task FetchFromFailedMessage(string uniqueMessageId) => + Task.FromResult(null); + + public Task StoreEventLogItem(EventLogItem logItem) => Task.CompletedTask; + + public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) => Task.CompletedTask; + + class NoOpEditFailedMessagesManager : IEditFailedMessagesManager + { + public void Dispose() + { + } + + public Task GetFailedMessage(string failedMessageId) => + Task.FromResult(null); + + public Task GetCurrentEditingRequestId(string failedMessageId) => + Task.FromResult(null); + + public Task SetCurrentEditingRequestId(string editingMessageId) => Task.CompletedTask; + + public Task SetFailedMessageAsResolved() => Task.CompletedTask; + + public Task UpdateFailedMessageBody(string uniqueMessageId, byte[] newBody) => Task.CompletedTask; + + public Task SaveChanges() => Task.CompletedTask; + } + + class NoOpNotificationsManager : INotificationsManager + { + public void Dispose() + { + } + + public Task LoadSettings(TimeSpan? cacheTimeout = null) => + Task.FromResult(null); + + public Task UpdateFailedMessageGroupDetails(string groupId, string title, FailedMessageStatus status) => + Task.CompletedTask; + + public Task SaveChanges() => Task.CompletedTask; + } +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpEventLogDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpEventLogDataStore.cs new file mode 100644 index 0000000000..6df1410891 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpEventLogDataStore.cs @@ -0,0 +1,15 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.EventLog; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; + +class NoOpEventLogDataStore : IEventLogDataStore +{ + public Task Add(EventLogItem logItem) => Task.CompletedTask; + + public Task<(IList items, long total, string version)> GetEventLogItems(PagingInfo pagingInfo) => + Task.FromResult<(IList, long, string)>(([], 0, string.Empty)); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpExternalIntegrationRequestsDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpExternalIntegrationRequestsDataStore.cs new file mode 100644 index 0000000000..805176e820 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpExternalIntegrationRequestsDataStore.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.ExternalIntegrations; +using ServiceControl.Persistence; + +class NoOpExternalIntegrationRequestsDataStore : IExternalIntegrationRequestsDataStore +{ + public void Subscribe(Func callback) + { + } + + public Task StoreDispatchRequest(IEnumerable dispatchRequests) => + Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpFailedErrorImportDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpFailedErrorImportDataStore.cs new file mode 100644 index 0000000000..0805c0cb88 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpFailedErrorImportDataStore.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.MessageFailures; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +class NoOpFailedErrorImportDataStore : IFailedErrorImportDataStore +{ + public Task ProcessFailedErrorImports(Func processMessage, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task QueryContainsFailedImports() => Task.FromResult(false); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpFailedMessageViewIndexNotifications.cs b/src/ServiceControl.Persistence.Sql/NoOpFailedMessageViewIndexNotifications.cs new file mode 100644 index 0000000000..ce9e1ac448 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpFailedMessageViewIndexNotifications.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Threading.Tasks; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; + +class NoOpFailedMessageViewIndexNotifications : IFailedMessageViewIndexNotifications +{ + public IDisposable Subscribe(Func callback) => new NoOpDisposable(); + + class NoOpDisposable : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpGroupsDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpGroupsDataStore.cs new file mode 100644 index 0000000000..ba571f405b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpGroupsDataStore.cs @@ -0,0 +1,15 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +class NoOpGroupsDataStore : IGroupsDataStore +{ + public Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter) => + Task.FromResult>([]); + + public Task GetCurrentForwardingBatch() => + Task.FromResult(null); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpMessageRedirectsDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpMessageRedirectsDataStore.cs new file mode 100644 index 0000000000..e7e890f062 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpMessageRedirectsDataStore.cs @@ -0,0 +1,12 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Threading.Tasks; +using ServiceControl.Persistence.MessageRedirects; + +class NoOpMessageRedirectsDataStore : IMessageRedirectsDataStore +{ + public Task GetOrCreate() => + Task.FromResult(new MessageRedirectsCollection()); + + public Task Save(MessageRedirectsCollection redirects) => Task.CompletedTask; +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpMonitoringDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpMonitoringDataStore.cs new file mode 100644 index 0000000000..bce203aa56 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpMonitoringDataStore.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +class NoOpMonitoringDataStore : IMonitoringDataStore +{ + public Task CreateIfNotExists(EndpointDetails endpoint) => Task.CompletedTask; + + public Task CreateOrUpdate(EndpointDetails endpoint, IEndpointInstanceMonitoring endpointInstanceMonitoring) => + Task.CompletedTask; + + public Task UpdateEndpointMonitoring(EndpointDetails endpoint, bool isMonitored) => Task.CompletedTask; + + public Task WarmupMonitoringFromPersistence(IEndpointInstanceMonitoring endpointInstanceMonitoring) => + Task.CompletedTask; + + public Task Delete(Guid endpointId) => Task.CompletedTask; + + public Task> GetAllKnownEndpoints() => + Task.FromResult>([]); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpQueueAddressStore.cs b/src/ServiceControl.Persistence.Sql/NoOpQueueAddressStore.cs new file mode 100644 index 0000000000..616b43910b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpQueueAddressStore.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; + +class NoOpQueueAddressStore : IQueueAddressStore +{ + static readonly QueryResult> EmptyResult = + new([], QueryStatsInfo.Zero); + + public Task>> GetAddresses(PagingInfo pagingInfo) => + Task.FromResult(EmptyResult); + + public Task>> GetAddressesBySearchTerm(string search, PagingInfo pagingInfo) => + Task.FromResult(EmptyResult); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpRetryBatchesDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpRetryBatchesDataStore.cs new file mode 100644 index 0000000000..1d4a9e4cf5 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpRetryBatchesDataStore.cs @@ -0,0 +1,85 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Persistence.MessageRedirects; +using ServiceControl.Recoverability; + +class NoOpRetryBatchesDataStore : IRetryBatchesDataStore +{ + public Task CreateRetryBatchesManager() => + Task.FromResult(new NoOpRetryBatchesManager()); + + public Task RecordFailedStagingAttempt(IReadOnlyCollection messages, + IReadOnlyDictionary failedMessageRetriesById, Exception e, int maxStagingAttempts, + string stagingId) => Task.CompletedTask; + + public Task IncrementAttemptCounter(FailedMessageRetry failedMessageRetry) => Task.CompletedTask; + + public Task DeleteFailedMessageRetry(string makeDocumentId) => Task.CompletedTask; + + class NoOpRetryBatchesManager : IRetryBatchesManager + { + public void Dispose() + { + } + + public Task GetBatch(string batchDocumentId) => + Task.FromResult(null); + + public Task>> GetBatches(string status, PagingInfo pagingInfo, + SortInfo sortInfo) => + Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task MarkBatchAsReadyForForwarding(string batchDocumentId) => Task.CompletedTask; + + public Task MarkMessageAsSuccessfullyForwarded(FailedMessageRetry messageRetryMetadata, string batchDocumentId) => + Task.CompletedTask; + + public Task MarkMessageAsPartOfBatch(string batchId, string uniqueMessageId, FailedMessageStatus status) => + Task.CompletedTask; + + public Task AbandonBatch(string batchDocumentId) => Task.CompletedTask; + + public void Delete(RetryBatch retryBatch) + { + } + + public void Delete(RetryBatchNowForwarding forwardingBatch) + { + } + + public Task GetFailedMessageRetries(IList stagingBatchFailureRetries) => + Task.FromResult(Array.Empty()); + + public void Evict(FailedMessageRetry failedMessageRetry) + { + } + + public Task GetFailedMessages(Dictionary.KeyCollection keys) => + Task.FromResult(Array.Empty()); + + public Task GetRetryBatchNowForwarding() => + Task.FromResult(null); + + public Task GetRetryBatch(string retryBatchId, CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task GetStagingBatch() => + Task.FromResult(null); + + public Task Store(RetryBatchNowForwarding retryBatchNowForwarding) => Task.CompletedTask; + + public Task GetOrCreateMessageRedirectsCollection() => + Task.FromResult(new MessageRedirectsCollection()); + + public Task CancelExpiration(FailedMessage failedMessage) => Task.CompletedTask; + + public Task SaveChanges() => Task.CompletedTask; + } +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpRetryDocumentDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpRetryDocumentDataStore.cs new file mode 100644 index 0000000000..a9b4a07826 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpRetryDocumentDataStore.cs @@ -0,0 +1,41 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +class NoOpRetryDocumentDataStore : IRetryDocumentDataStore +{ + public Task StageRetryByUniqueMessageIds(string batchDocumentId, string[] messageIds) => Task.CompletedTask; + + public Task MoveBatchToStaging(string batchDocumentId) => Task.CompletedTask; + + public Task CreateBatchDocument(string retrySessionId, string requestId, RetryType retryType, + string[] failedMessageRetryIds, string originator, DateTime startTime, DateTime? last = null, + string batchName = null, string classifier = null) => + Task.FromResult(string.Empty); + + public Task>> QueryOrphanedBatches(string retrySessionId) => + Task.FromResult(new QueryResult>([], QueryStatsInfo.Zero)); + + public Task> QueryAvailableBatches() => + Task.FromResult>([]); + + public Task GetBatchesForAll(DateTime cutoff, Func callback) => Task.CompletedTask; + + public Task GetBatchesForEndpoint(DateTime cutoff, string endpoint, Func callback) => + Task.CompletedTask; + + public Task GetBatchesForFailedQueueAddress(DateTime cutoff, string failedQueueAddresspoint, + FailedMessageStatus status, Func callback) => Task.CompletedTask; + + public Task GetBatchesForFailureGroup(string groupId, string groupTitle, string groupType, DateTime cutoff, + Func callback) => Task.CompletedTask; + + public Task QueryFailureGroupViewOnGroupId(string groupId) => + Task.FromResult(null); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpRetryHistoryDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpRetryHistoryDataStore.cs new file mode 100644 index 0000000000..0a1ed9240c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpRetryHistoryDataStore.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Threading.Tasks; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +class NoOpRetryHistoryDataStore : IRetryHistoryDataStore +{ + public Task GetRetryHistory() => + Task.FromResult(null); + + public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, + DateTime completionTime, string originator, string classifier, bool messageFailed, + int numberOfMessagesProcessed, DateTime lastProcessed, int retryHistoryDepth) => Task.CompletedTask; + + public Task AcknowledgeRetryGroup(string groupId) => Task.FromResult(false); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs b/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs new file mode 100644 index 0000000000..d398810881 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NServiceBus; +using NServiceBus.Extensibility; +using NServiceBus.Unicast.Subscriptions; +using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; +using ServiceControl.Persistence; + +class NoOpServiceControlSubscriptionStorage : IServiceControlSubscriptionStorage +{ + public Task Initialize() => Task.CompletedTask; + + public Task Subscribe(Subscriber subscriber, MessageType messageType, ContextBag context, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task Unsubscribe(Subscriber subscriber, MessageType messageType, ContextBag context, + CancellationToken cancellationToken) => Task.CompletedTask; + + public Task> GetSubscriberAddressesForMessage(IEnumerable messageTypes, + ContextBag context, CancellationToken cancellationToken) => + Task.FromResult>([]); +} diff --git a/src/ServiceControl.Persistence.Sql/NoOpTrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql/NoOpTrialLicenseDataProvider.cs new file mode 100644 index 0000000000..ff80950278 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpTrialLicenseDataProvider.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Threading; +using System.Threading.Tasks; + +class NoOpTrialLicenseDataProvider : ITrialLicenseDataProvider +{ + static readonly DateOnly FutureDate = new DateOnly(2099, 12, 31); + + public Task GetTrialEndDate(CancellationToken cancellationToken) => + Task.FromResult(FutureDate); + + public Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken) => + Task.CompletedTask; +} diff --git a/src/ServiceControl.Persistence.Sql/ServiceControl.Persistence.Sql.csproj b/src/ServiceControl.Persistence.Sql/ServiceControl.Persistence.Sql.csproj new file mode 100644 index 0000000000..f509649fe9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/ServiceControl.Persistence.Sql.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + true + true + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql/SqlPersistence.cs b/src/ServiceControl.Persistence.Sql/SqlPersistence.cs new file mode 100644 index 0000000000..89572b4198 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/SqlPersistence.cs @@ -0,0 +1,40 @@ +namespace ServiceControl.Persistence.Sql; + +using Microsoft.Extensions.DependencyInjection; +using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; +using ServiceControl.Operations.BodyStorage; +using ServiceControl.Persistence; +using ServiceControl.Persistence.MessageRedirects; +using ServiceControl.Persistence.Recoverability; + +class SqlPersistence : IPersistence +{ + public void AddPersistence(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + } +} diff --git a/src/ServiceControl.Persistence.Sql/persistence.manifest b/src/ServiceControl.Persistence.Sql/persistence.manifest new file mode 100644 index 0000000000..0b3210f7c4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/persistence.manifest @@ -0,0 +1,7 @@ +{ + "Name": "Sql", + "DisplayName": "Sql", + "Description": "Sql ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql", + "TypeName": "ServiceControl.Persistence.Sql.RavenPersistenceConfiguration, ServiceControl.Persistence.Sql" +} diff --git a/src/ServiceControl.sln b/src/ServiceControl.sln index fa8d9a30e6..b8a8f676c0 100644 --- a/src/ServiceControl.sln +++ b/src/ServiceControl.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31815.197 @@ -187,6 +188,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Hosting", "S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetupProcessFake", "SetupProcessFake\SetupProcessFake.csproj", "{5837F789-69B9-44BE-B114-3A2880F06CAB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql", "ServiceControl.Persistence.Sql\ServiceControl.Persistence.Sql.csproj", "{07D7A850-3164-4C27-BE22-FD8A97C06EF3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1025,6 +1028,18 @@ Global {5837F789-69B9-44BE-B114-3A2880F06CAB}.Release|x64.Build.0 = Release|Any CPU {5837F789-69B9-44BE-B114-3A2880F06CAB}.Release|x86.ActiveCfg = Release|Any CPU {5837F789-69B9-44BE-B114-3A2880F06CAB}.Release|x86.Build.0 = Release|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x64.ActiveCfg = Debug|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x64.Build.0 = Debug|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x86.ActiveCfg = Debug|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x86.Build.0 = Debug|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|Any CPU.Build.0 = Release|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x64.ActiveCfg = Release|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x64.Build.0 = Release|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.ActiveCfg = Release|Any CPU + {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1110,6 +1125,7 @@ Global {18DBEEF5-42EE-4C1D-A05B-87B21C067D53} = {E0E45F22-35E3-4AD8-B09E-EFEA5A2F18EE} {481032A1-1106-4C6C-B75E-512F2FB08882} = {9AF9D3C7-E859-451B-BA4D-B954D289213A} {5837F789-69B9-44BE-B114-3A2880F06CAB} = {927A078A-E271-4878-A153-86D71AE510E2} + {07D7A850-3164-4C27-BE22-FD8A97C06EF3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3B9E5B72-F580-465A-A22C-2D2148AF4EB4} From 26ed1079a7a1ee377da3c6df4d7f5d324dda5d52 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 8 Dec 2025 16:05:03 +1000 Subject: [PATCH 02/16] Edding EF --- src/Directory.Packages.props | 9 ++ .../.editorconfig | 4 + .../Abstractions/IDatabaseMigrator.cs | 6 + .../Abstractions/SqlPersisterSettings.cs | 10 ++ .../DbContexts/ServiceControlDbContextBase.cs | 27 +++++ .../Entities/TrialLicenseEntity.cs | 7 ++ .../TrialLicenseConfiguration.cs | 23 ++++ .../TrialLicenseDataProvider.cs | 56 ++++++++++ ...ServiceControl.Persistence.Sql.Core.csproj | 19 ++++ .../.editorconfig | 4 + .../20241208000000_InitialCreate.cs | 32 ++++++ .../Migrations/MySqlDbContextModelSnapshot.cs | 38 +++++++ .../MySqlDatabaseMigrator.cs | 48 ++++++++ .../MySqlDbContext.cs | 16 +++ .../MySqlDbContextFactory.cs | 19 ++++ .../MySqlPersistence.cs | 65 +++++++++++ .../MySqlPersistenceConfiguration.cs | 35 ++++++ .../MySqlPersisterSettings.cs | 8 ++ ...erviceControl.Persistence.Sql.MySQL.csproj | 28 +++++ .../persistence.manifest | 13 +++ .../.editorconfig | 4 + .../20241208000000_InitialCreate.cs | 32 ++++++ .../PostgreSqlDbContextModelSnapshot.cs | 40 +++++++ .../PostgreSqlDatabaseMigrator.cs | 48 ++++++++ .../PostgreSqlDbContext.cs | 32 ++++++ .../PostgreSqlDbContextFactory.cs | 18 +++ .../PostgreSqlPersistence.cs | 65 +++++++++++ .../PostgreSqlPersistenceConfiguration.cs | 35 ++++++ .../PostgreSqlPersisterSettings.cs | 8 ++ ...eControl.Persistence.Sql.PostgreSQL.csproj | 28 +++++ .../persistence.manifest | 13 +++ .../.editorconfig | 4 + .../20241208000000_InitialCreate.cs | 32 ++++++ .../SqlServerDbContextModelSnapshot.cs | 38 +++++++ ...ceControl.Persistence.Sql.SqlServer.csproj | 28 +++++ .../SqlServerDatabaseMigrator.cs | 48 ++++++++ .../SqlServerDbContext.cs | 16 +++ .../SqlServerDbContextFactory.cs | 18 +++ .../SqlServerPersistence.cs | 67 +++++++++++ .../SqlServerPersistenceConfiguration.cs | 35 ++++++ .../SqlServerPersisterSettings.cs | 8 ++ .../persistence.manifest | 13 +++ .../.editorconfig | 4 + .../PersistenceTestsContext.cs | 64 +++++++++++ ...Control.Persistence.Tests.Sql.MySQL.csproj | 31 ++++++ .../TrialLicenseDataProviderTests.cs | 51 +++++++++ .../.editorconfig | 4 + .../PersistenceTestsContext.cs | 64 +++++++++++ ...ol.Persistence.Tests.Sql.PostgreSQL.csproj | 31 ++++++ .../TrialLicenseDataProviderTests.cs | 51 +++++++++ .../.editorconfig | 4 + .../PersistenceTestsContext.cs | 62 +++++++++++ ...rol.Persistence.Tests.Sql.SqlServer.csproj | 31 ++++++ .../TrialLicenseDataProviderTests.cs | 51 +++++++++ src/ServiceControl.sln | 105 ++++++++++++++++++ 55 files changed, 1650 insertions(+) create mode 100644 src/ServiceControl.Persistence.Sql.Core/.editorconfig create mode 100644 src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/.editorconfig create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest create mode 100644 src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig create mode 100644 src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs create mode 100644 src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj create mode 100644 src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig create mode 100644 src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs create mode 100644 src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj create mode 100644 src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig create mode 100644 src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs create mode 100644 src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj create mode 100644 src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7e49d6d805..f0987c5c92 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -19,6 +19,10 @@ + + + + @@ -50,6 +54,7 @@ + @@ -62,6 +67,7 @@ + @@ -78,6 +84,9 @@ + + + diff --git a/src/ServiceControl.Persistence.Sql.Core/.editorconfig b/src/ServiceControl.Persistence.Sql.Core/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs new file mode 100644 index 0000000000..39de35cfe4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Persistence.Sql.Core.Abstractions; + +public interface IDatabaseMigrator +{ + Task ApplyMigrations(CancellationToken cancellationToken = default); +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs new file mode 100644 index 0000000000..25de65b710 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Abstractions; + +using ServiceControl.Persistence; + +public abstract class SqlPersisterSettings : PersistenceSettings +{ + public required string ConnectionString { get; set; } + public int CommandTimeout { get; set; } = 30; + public bool EnableSensitiveDataLogging { get; set; } = false; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs new file mode 100644 index 0000000000..ec07bed3ee --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -0,0 +1,27 @@ +namespace ServiceControl.Persistence.Sql.Core.DbContexts; + +using Entities; +using EntityConfigurations; +using Microsoft.EntityFrameworkCore; + +public abstract class ServiceControlDbContextBase : DbContext +{ + protected ServiceControlDbContextBase(DbContextOptions options) : base(options) + { + } + + public DbSet TrialLicenses { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfiguration(new TrialLicenseConfiguration()); + + OnModelCreatingProvider(modelBuilder); + } + + protected virtual void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs new file mode 100644 index 0000000000..36659760c0 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs @@ -0,0 +1,7 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class TrialLicenseEntity +{ + public int Id { get; set; } + public DateOnly TrialEndDate { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs new file mode 100644 index 0000000000..a00d3277c0 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs @@ -0,0 +1,23 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class TrialLicenseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TrialLicense"); + + builder.HasKey(e => e.Id); + + // Ensure only one row exists by using a fixed primary key + builder.Property(e => e.Id) + .HasDefaultValue(1) + .ValueGeneratedNever(); + + builder.Property(e => e.TrialEndDate) + .IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs new file mode 100644 index 0000000000..80cdce3eae --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs @@ -0,0 +1,56 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +public class TrialLicenseDataProvider : ITrialLicenseDataProvider +{ + readonly IServiceProvider serviceProvider; + const int SingletonId = 1; + + public TrialLicenseDataProvider(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public async Task GetTrialEndDate(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var entity = await dbContext.TrialLicenses + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); + + return entity?.TrialEndDate; + } + + public async Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var existingEntity = await dbContext.TrialLicenses + .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); + + if (existingEntity != null) + { + // Update existing + existingEntity.TrialEndDate = trialEndDate; + } + else + { + // Insert new + var newEntity = new Entities.TrialLicenseEntity + { + Id = SingletonId, + TrialEndDate = trialEndDate + }; + await dbContext.TrialLicenses.AddAsync(newEntity, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj new file mode 100644 index 0000000000..2b1582e206 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs new file mode 100644 index 0000000000..160fcdabd5 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Sql.MySQL.Migrations; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + Id = table.Column(type: "int", nullable: false, defaultValue: 1), + TrialEndDate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrialLicense", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrialLicense"); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs new file mode 100644 index 0000000000..182e94615d --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -0,0 +1,38 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using ServiceControl.Persistence.Sql.MySQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + [DbContext(typeof(MySqlDbContext))] + partial class MySqlDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs new file mode 100644 index 0000000000..9a7ab109e8 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +class MySqlDatabaseMigrator : IDatabaseMigrator +{ + readonly IServiceProvider serviceProvider; + readonly ILogger logger; + + public MySqlDatabaseMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + public async Task ApplyMigrations(CancellationToken cancellationToken) + { + logger.LogInformation("Initializing MySQL database"); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Testing database connectivity"); + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + throw new Exception("Cannot connect to MySQL database. Check connection string and ensure database server is accessible."); + } + + logger.LogInformation("Applying pending migrations"); + await dbContext.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("MySQL database initialized successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize MySQL database"); + throw; + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs new file mode 100644 index 0000000000..db430f16b8 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class MySqlDbContext : ServiceControlDbContextBase +{ + public MySqlDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + // MySQL-specific configurations if needed + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs new file mode 100644 index 0000000000..539612142d --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +// Design-time factory for EF Core tools (migrations, etc.) +class MySqlDbContextFactory : IDesignTimeDbContextFactory +{ + public MySqlDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use a dummy connection string for design-time operations + var connectionString = "Server=localhost;Database=servicecontrol;User=root;Password=mysql"; + optionsBuilder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); + + return new MySqlDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs new file mode 100644 index 0000000000..08a70a5671 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs @@ -0,0 +1,65 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; +using Core.DbContexts; +using Core.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +class MySqlPersistence : IPersistence +{ + readonly MySqlPersisterSettings settings; + + public MySqlPersistence(MySqlPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + + if (settings.MaintenanceMode) + { + return; + } + + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + ConfigureDbContext(services); + + services.AddSingleton(); + } + + void ConfigureDbContext(IServiceCollection services) + { + services.AddSingleton(settings); + services.AddSingleton(settings); + + services.AddDbContext((serviceProvider, options) => + { + options.UseMySql(settings.ConnectionString, ServerVersion.AutoDetect(settings.ConnectionString), mySqlOptions => + { + mySqlOptions.CommandTimeout(settings.CommandTimeout); + if (settings.EnableRetryOnFailure) + { + mySqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + } + }); + + if (settings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + }, ServiceLifetime.Scoped); + + services.AddScoped(sp => sp.GetRequiredService()); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs new file mode 100644 index 0000000000..7cd3303d3f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Configuration; +using ServiceControl.Persistence; + +public class MySqlPersistenceConfiguration : IPersistenceConfiguration +{ + const string DatabaseConnectionStringKey = "Database/ConnectionString"; + const string CommandTimeoutKey = "Database/CommandTimeout"; + + public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) + { + var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception($"Setting {DatabaseConnectionStringKey} is required for MySQL persistence. " + + $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); + } + + var settings = new MySqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + }; + + return settings; + } + + public IPersistence Create(PersistenceSettings settings) + { + var specificSettings = (MySqlPersisterSettings)settings; + return new MySqlPersistence(specificSettings); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs new file mode 100644 index 0000000000..b69f72d2e4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; + +public class MySqlPersisterSettings : SqlPersisterSettings +{ + public bool EnableRetryOnFailure { get; set; } = true; +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj new file mode 100644 index 0000000000..5cdc8c7401 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest b/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest new file mode 100644 index 0000000000..db462a730c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest @@ -0,0 +1,13 @@ +{ + "Name": "MySQL", + "DisplayName": "MySQL", + "Description": "MySQL ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql.MySQL", + "TypeName": "ServiceControl.Persistence.Sql.MySQL.MySqlPersistenceConfiguration, ServiceControl.Persistence.Sql.MySQL", + "Settings": [ + { + "Name": "ServiceControl/Database/ConnectionString", + "Mandatory": true + } + ] +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs new file mode 100644 index 0000000000..b830a81b63 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "triallicense", + columns: table => new + { + id = table.Column(type: "integer", nullable: false, defaultValue: 1), + trialenddate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_triallicense", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "triallicense"); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs new file mode 100644 index 0000000000..f342bfcb71 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -0,0 +1,40 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using ServiceControl.Persistence.Sql.PostgreSQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + [DbContext(typeof(PostgreSqlDbContext))] + partial class PostgreSqlDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date") + .HasColumnName("trialenddate"); + + b.HasKey("Id"); + + b.ToTable("triallicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs new file mode 100644 index 0000000000..68d8ac14eb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +class PostgreSqlDatabaseMigrator : IDatabaseMigrator +{ + readonly IServiceProvider serviceProvider; + readonly ILogger logger; + + public PostgreSqlDatabaseMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + public async Task ApplyMigrations(CancellationToken cancellationToken) + { + logger.LogInformation("Initializing PostgreSQL database"); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Testing database connectivity"); + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + throw new Exception("Cannot connect to PostgreSQL database. Check connection string and ensure database server is accessible."); + } + + logger.LogInformation("Applying pending migrations"); + await dbContext.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("PostgreSQL database initialized successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize PostgreSQL database"); + throw; + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs new file mode 100644 index 0000000000..c49a0e387f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class PostgreSqlDbContext : ServiceControlDbContextBase +{ + public PostgreSqlDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Apply lowercase naming convention for PostgreSQL + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + entity.SetTableName(entity.GetTableName()?.ToLowerInvariant()); + + foreach (var property in entity.GetProperties()) + { + property.SetColumnName(property.GetColumnName().ToLowerInvariant()); + } + } + + base.OnModelCreating(modelBuilder); + } + + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + // PostgreSQL-specific configurations if needed + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs new file mode 100644 index 0000000000..6fd19a453b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +// Design-time factory for EF Core tools (migrations, etc.) +class PostgreSqlDbContextFactory : IDesignTimeDbContextFactory +{ + public PostgreSqlDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use a dummy connection string for design-time operations + optionsBuilder.UseNpgsql("Host=localhost;Database=servicecontrol;Username=postgres;Password=postgres"); + + return new PostgreSqlDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs new file mode 100644 index 0000000000..7bbc1b9ae2 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -0,0 +1,65 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; +using Core.DbContexts; +using Core.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +class PostgreSqlPersistence : IPersistence +{ + readonly PostgreSqlPersisterSettings settings; + + public PostgreSqlPersistence(PostgreSqlPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + + if (settings.MaintenanceMode) + { + return; + } + + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + ConfigureDbContext(services); + + services.AddSingleton(); + } + + void ConfigureDbContext(IServiceCollection services) + { + services.AddSingleton(settings); + services.AddSingleton(settings); + + services.AddDbContext((serviceProvider, options) => + { + options.UseNpgsql(settings.ConnectionString, npgsqlOptions => + { + npgsqlOptions.CommandTimeout(settings.CommandTimeout); + if (settings.EnableRetryOnFailure) + { + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + } + }); + + if (settings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + }, ServiceLifetime.Scoped); + + services.AddScoped(sp => sp.GetRequiredService()); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs new file mode 100644 index 0000000000..99b81c0406 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Configuration; +using ServiceControl.Persistence; + +public class PostgreSqlPersistenceConfiguration : IPersistenceConfiguration +{ + const string DatabaseConnectionStringKey = "Database/ConnectionString"; + const string CommandTimeoutKey = "Database/CommandTimeout"; + + public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) + { + var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception($"Setting {DatabaseConnectionStringKey} is required for PostgreSQL persistence. " + + $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); + } + + var settings = new PostgreSqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + }; + + return settings; + } + + public IPersistence Create(PersistenceSettings settings) + { + var specificSettings = (PostgreSqlPersisterSettings)settings; + return new PostgreSqlPersistence(specificSettings); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs new file mode 100644 index 0000000000..c0352984c6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; + +public class PostgreSqlPersisterSettings : SqlPersisterSettings +{ + public bool EnableRetryOnFailure { get; set; } = true; +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj new file mode 100644 index 0000000000..3add7d15e1 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest b/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest new file mode 100644 index 0000000000..e04bb32751 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest @@ -0,0 +1,13 @@ +{ + "Name": "PostgreSQL", + "DisplayName": "PostgreSQL", + "Description": "PostgreSQL ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql.PostgreSQL", + "TypeName": "ServiceControl.Persistence.Sql.PostgreSQL.PostgreSqlPersistenceConfiguration, ServiceControl.Persistence.Sql.PostgreSQL", + "Settings": [ + { + "Name": "ServiceControl/Database/ConnectionString", + "Mandatory": true + } + ] +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs new file mode 100644 index 0000000000..bbb196af77 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + Id = table.Column(type: "int", nullable: false, defaultValue: 1), + TrialEndDate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrialLicense", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrialLicense"); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs new file mode 100644 index 0000000000..b994999482 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -0,0 +1,38 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using ServiceControl.Persistence.Sql.SqlServer; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + [DbContext(typeof(SqlServerDbContext))] + partial class SqlServerDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj b/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj new file mode 100644 index 0000000000..50e155fead --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs new file mode 100644 index 0000000000..2cd8812506 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +class SqlServerDatabaseMigrator : IDatabaseMigrator +{ + readonly IServiceProvider serviceProvider; + readonly ILogger logger; + + public SqlServerDatabaseMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + public async Task ApplyMigrations(CancellationToken cancellationToken) + { + logger.LogInformation("Initializing SQL Server database"); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Testing database connectivity"); + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + throw new Exception("Cannot connect to SQL Server database. Check connection string and ensure database server is accessible."); + } + + logger.LogInformation("Applying pending migrations"); + await dbContext.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("SQL Server database initialized successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize SQL Server database"); + throw; + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs new file mode 100644 index 0000000000..aa53ad4c67 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class SqlServerDbContext : ServiceControlDbContextBase +{ + public SqlServerDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + // SQL Server-specific configurations if needed + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs new file mode 100644 index 0000000000..6bbe125407 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +// Design-time factory for EF Core tools (migrations, etc.) +class SqlServerDbContextFactory : IDesignTimeDbContextFactory +{ + public SqlServerDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use a dummy connection string for design-time operations + optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ServiceControl;Trusted_Connection=True;"); + + return new SqlServerDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs new file mode 100644 index 0000000000..4073bfc3ad --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; +using Core.DbContexts; +using Core.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +class SqlServerPersistence : IPersistence +{ + readonly SqlServerPersisterSettings settings; + + public SqlServerPersistence(SqlServerPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + + if (settings.MaintenanceMode) + { + return; + } + + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + ConfigureDbContext(services); + + // Register the database migrator - this runs during installation/setup + services.AddSingleton(); + } + + void ConfigureDbContext(IServiceCollection services) + { + services.AddSingleton(settings); + services.AddSingleton(settings); + + services.AddDbContext((serviceProvider, options) => + { + options.UseSqlServer(settings.ConnectionString, sqlOptions => + { + sqlOptions.CommandTimeout(settings.CommandTimeout); + if (settings.EnableRetryOnFailure) + { + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + } + }); + + if (settings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + }, ServiceLifetime.Scoped); + + // Register as base type for TrialLicenseDataProvider + services.AddScoped(sp => sp.GetRequiredService()); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs new file mode 100644 index 0000000000..7972c2a801 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Configuration; +using ServiceControl.Persistence; + +public class SqlServerPersistenceConfiguration : IPersistenceConfiguration +{ + const string DatabaseConnectionStringKey = "Database/ConnectionString"; + const string CommandTimeoutKey = "Database/CommandTimeout"; + + public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) + { + var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception($"Setting {DatabaseConnectionStringKey} is required for SQL Server persistence. " + + $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); + } + + var settings = new SqlServerPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + }; + + return settings; + } + + public IPersistence Create(PersistenceSettings settings) + { + var specificSettings = (SqlServerPersisterSettings)settings; + return new SqlServerPersistence(specificSettings); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs new file mode 100644 index 0000000000..144c853ecc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; + +public class SqlServerPersisterSettings : SqlPersisterSettings +{ + public bool EnableRetryOnFailure { get; set; } = true; +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest b/src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest new file mode 100644 index 0000000000..11392f992c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest @@ -0,0 +1,13 @@ +{ + "Name": "SqlServer", + "DisplayName": "SQL Server", + "Description": "SQL Server ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql.SqlServer", + "TypeName": "ServiceControl.Persistence.Sql.SqlServer.SqlServerPersistenceConfiguration, ServiceControl.Persistence.Sql.SqlServer", + "Settings": [ + { + "Name": "ServiceControl/Database/ConnectionString", + "Mandatory": true + } + ] +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig b/src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs new file mode 100644 index 0000000000..ea197fa88b --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Sql.Core.Abstractions; +using ServiceControl.Persistence.Sql.MySQL; +using Testcontainers.MySql; + +public class PersistenceTestsContext : IPersistenceTestsContext +{ + MySqlContainer mySqlContainer; + + public async Task Setup(IHostApplicationBuilder hostBuilder) + { + mySqlContainer = new MySqlBuilder() + .WithImage("mysql:8.0") + .WithDatabase("servicecontrol") + .WithUsername("root") + .WithPassword("mysql") + .Build(); + + await mySqlContainer.StartAsync(); + + var connectionString = mySqlContainer.GetConnectionString(); + + PersistenceSettings = new MySqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = 30, + MaintenanceMode = false + }; + + var persistence = new MySqlPersistenceConfiguration().Create(PersistenceSettings); + persistence.AddPersistence(hostBuilder.Services); + persistence.AddInstaller(hostBuilder.Services); + } + + public async Task PostSetup(IHost host) + { + // Apply migrations + var migrator = host.Services.GetRequiredService(); + await migrator.ApplyMigrations(); + } + + public async Task TearDown() + { + if (mySqlContainer != null) + { + await mySqlContainer.StopAsync(); + await mySqlContainer.DisposeAsync(); + } + } + + public PersistenceSettings PersistenceSettings { get; private set; } + + public void CompleteDatabaseOperation() + { + // No-op for SQL (no async indexing like RavenDB) + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj b/src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj new file mode 100644 index 0000000000..974855eebe --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs new file mode 100644 index 0000000000..38ccad0235 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using ServiceControl.Persistence; + +[TestFixture] +class TrialLicenseDataProviderTests +{ + [Test] + public async Task Should_store_and_retrieve_trial_end_date() + { + var context = new PersistenceTestsContext(); + var hostBuilder = Host.CreateApplicationBuilder(); + + await context.Setup(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(); + + await context.PostSetup(host); + + var provider = host.Services.GetRequiredService(); + + // Initially should be null + var initialValue = await provider.GetTrialEndDate(default); + Assert.That(initialValue, Is.Null); + + // Store a trial end date + var expectedDate = new DateOnly(2025, 12, 31); + await provider.StoreTrialEndDate(expectedDate, default); + + // Retrieve and verify + var retrievedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedDate, Is.EqualTo(expectedDate)); + + // Update the trial end date + var updatedDate = new DateOnly(2026, 6, 30); + await provider.StoreTrialEndDate(updatedDate, default); + + // Retrieve and verify update + var retrievedUpdatedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedUpdatedDate, Is.EqualTo(updatedDate)); + + await host.StopAsync(); + await context.TearDown(); + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs new file mode 100644 index 0000000000..bebc1fa27a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Sql.Core.Abstractions; +using ServiceControl.Persistence.Sql.PostgreSQL; +using Testcontainers.PostgreSql; + +public class PersistenceTestsContext : IPersistenceTestsContext +{ + PostgreSqlContainer postgreSqlContainer; + + public async Task Setup(IHostApplicationBuilder hostBuilder) + { + postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("servicecontrol") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + await postgreSqlContainer.StartAsync(); + + var connectionString = postgreSqlContainer.GetConnectionString(); + + PersistenceSettings = new PostgreSqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = 30, + MaintenanceMode = false + }; + + var persistence = new PostgreSqlPersistenceConfiguration().Create(PersistenceSettings); + persistence.AddPersistence(hostBuilder.Services); + persistence.AddInstaller(hostBuilder.Services); + } + + public async Task PostSetup(IHost host) + { + // Apply migrations + var migrator = host.Services.GetRequiredService(); + await migrator.ApplyMigrations(); + } + + public async Task TearDown() + { + if (postgreSqlContainer != null) + { + await postgreSqlContainer.StopAsync(); + await postgreSqlContainer.DisposeAsync(); + } + } + + public PersistenceSettings PersistenceSettings { get; private set; } + + public void CompleteDatabaseOperation() + { + // No-op for SQL (no async indexing like RavenDB) + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj new file mode 100644 index 0000000000..7c294e994b --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs new file mode 100644 index 0000000000..38ccad0235 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using ServiceControl.Persistence; + +[TestFixture] +class TrialLicenseDataProviderTests +{ + [Test] + public async Task Should_store_and_retrieve_trial_end_date() + { + var context = new PersistenceTestsContext(); + var hostBuilder = Host.CreateApplicationBuilder(); + + await context.Setup(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(); + + await context.PostSetup(host); + + var provider = host.Services.GetRequiredService(); + + // Initially should be null + var initialValue = await provider.GetTrialEndDate(default); + Assert.That(initialValue, Is.Null); + + // Store a trial end date + var expectedDate = new DateOnly(2025, 12, 31); + await provider.StoreTrialEndDate(expectedDate, default); + + // Retrieve and verify + var retrievedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedDate, Is.EqualTo(expectedDate)); + + // Update the trial end date + var updatedDate = new DateOnly(2026, 6, 30); + await provider.StoreTrialEndDate(updatedDate, default); + + // Retrieve and verify update + var retrievedUpdatedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedUpdatedDate, Is.EqualTo(updatedDate)); + + await host.StopAsync(); + await context.TearDown(); + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs new file mode 100644 index 0000000000..9d8ae3ddb3 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs @@ -0,0 +1,62 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Sql.Core.Abstractions; +using ServiceControl.Persistence.Sql.SqlServer; +using Testcontainers.MsSql; + +public class PersistenceTestsContext : IPersistenceTestsContext +{ + MsSqlContainer sqlServerContainer; + + public async Task Setup(IHostApplicationBuilder hostBuilder) + { + sqlServerContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("YourStrong@Passw0rd") + .Build(); + + await sqlServerContainer.StartAsync(); + + var connectionString = sqlServerContainer.GetConnectionString(); + + PersistenceSettings = new SqlServerPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = 30, + MaintenanceMode = false + }; + + var persistence = new SqlServerPersistenceConfiguration().Create(PersistenceSettings); + persistence.AddPersistence(hostBuilder.Services); + persistence.AddInstaller(hostBuilder.Services); + } + + public async Task PostSetup(IHost host) + { + // Apply migrations + var migrator = host.Services.GetRequiredService(); + await migrator.ApplyMigrations(); + } + + public async Task TearDown() + { + if (sqlServerContainer != null) + { + await sqlServerContainer.StopAsync(); + await sqlServerContainer.DisposeAsync(); + } + } + + public PersistenceSettings PersistenceSettings { get; private set; } + + public void CompleteDatabaseOperation() + { + // No-op for SQL (no async indexing like RavenDB) + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj new file mode 100644 index 0000000000..4ba0f5afb0 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs new file mode 100644 index 0000000000..38ccad0235 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using ServiceControl.Persistence; + +[TestFixture] +class TrialLicenseDataProviderTests +{ + [Test] + public async Task Should_store_and_retrieve_trial_end_date() + { + var context = new PersistenceTestsContext(); + var hostBuilder = Host.CreateApplicationBuilder(); + + await context.Setup(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(); + + await context.PostSetup(host); + + var provider = host.Services.GetRequiredService(); + + // Initially should be null + var initialValue = await provider.GetTrialEndDate(default); + Assert.That(initialValue, Is.Null); + + // Store a trial end date + var expectedDate = new DateOnly(2025, 12, 31); + await provider.StoreTrialEndDate(expectedDate, default); + + // Retrieve and verify + var retrievedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedDate, Is.EqualTo(expectedDate)); + + // Update the trial end date + var updatedDate = new DateOnly(2026, 6, 30); + await provider.StoreTrialEndDate(updatedDate, default); + + // Retrieve and verify update + var retrievedUpdatedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedUpdatedDate, Is.EqualTo(updatedDate)); + + await host.StopAsync(); + await context.TearDown(); + } +} diff --git a/src/ServiceControl.sln b/src/ServiceControl.sln index b8a8f676c0..c6063c8dff 100644 --- a/src/ServiceControl.sln +++ b/src/ServiceControl.sln @@ -190,6 +190,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetupProcessFake", "SetupPr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql", "ServiceControl.Persistence.Sql\ServiceControl.Persistence.Sql.csproj", "{07D7A850-3164-4C27-BE22-FD8A97C06EF3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.Core", "ServiceControl.Persistence.Sql.Core\ServiceControl.Persistence.Sql.Core.csproj", "{7C7239A8-E56B-4A89-9028-80B2A416E989}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.SqlServer", "ServiceControl.Persistence.Sql.SqlServer\ServiceControl.Persistence.Sql.SqlServer.csproj", "{B1177EF2-9022-49D8-B282-DDF494B79CFF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.PostgreSQL", "ServiceControl.Persistence.Sql.PostgreSQL\ServiceControl.Persistence.Sql.PostgreSQL.csproj", "{7A42C8BE-01C9-42F3-B15B-9365940D3FC3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.MySQL", "ServiceControl.Persistence.Sql.MySQL\ServiceControl.Persistence.Sql.MySQL.csproj", "{13F6F4DA-D447-4968-82E2-D4B0897B605E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Tests.Sql.SqlServer", "ServiceControl.Persistence.Tests.Sql.SqlServer\ServiceControl.Persistence.Tests.Sql.SqlServer.csproj", "{DA015F58-BD32-48AF-848D-74DEA5E6B905}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Tests.Sql.PostgreSQL", "ServiceControl.Persistence.Tests.Sql.PostgreSQL\ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj", "{B1726E92-FD6E-4628-BD20-148095281E1D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Tests.Sql.MySQL", "ServiceControl.Persistence.Tests.Sql.MySQL\ServiceControl.Persistence.Tests.Sql.MySQL.csproj", "{00984992-0ED5-40F1-8821-0B6367D05968}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1040,6 +1054,90 @@ Global {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x64.Build.0 = Release|Any CPU {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.ActiveCfg = Release|Any CPU {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.Build.0 = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x64.Build.0 = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x86.Build.0 = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|Any CPU.Build.0 = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x64.ActiveCfg = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x64.Build.0 = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x86.ActiveCfg = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x86.Build.0 = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x64.Build.0 = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x86.Build.0 = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|Any CPU.Build.0 = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x64.ActiveCfg = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x64.Build.0 = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x86.ActiveCfg = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x86.Build.0 = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x64.Build.0 = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x86.Build.0 = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|Any CPU.Build.0 = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x64.ActiveCfg = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x64.Build.0 = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x86.ActiveCfg = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x86.Build.0 = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x64.ActiveCfg = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x64.Build.0 = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x86.ActiveCfg = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x86.Build.0 = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|Any CPU.Build.0 = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x64.ActiveCfg = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x64.Build.0 = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x86.ActiveCfg = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x86.Build.0 = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x64.Build.0 = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x86.Build.0 = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|Any CPU.Build.0 = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x64.ActiveCfg = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x64.Build.0 = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x86.ActiveCfg = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x86.Build.0 = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x64.Build.0 = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x86.Build.0 = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|Any CPU.Build.0 = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x64.ActiveCfg = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x64.Build.0 = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x86.ActiveCfg = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x86.Build.0 = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x64.ActiveCfg = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x64.Build.0 = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x86.ActiveCfg = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x86.Build.0 = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|Any CPU.Build.0 = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x64.ActiveCfg = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x64.Build.0 = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x86.ActiveCfg = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1126,6 +1224,13 @@ Global {481032A1-1106-4C6C-B75E-512F2FB08882} = {9AF9D3C7-E859-451B-BA4D-B954D289213A} {5837F789-69B9-44BE-B114-3A2880F06CAB} = {927A078A-E271-4878-A153-86D71AE510E2} {07D7A850-3164-4C27-BE22-FD8A97C06EF3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {7C7239A8-E56B-4A89-9028-80B2A416E989} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {B1177EF2-9022-49D8-B282-DDF494B79CFF} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {13F6F4DA-D447-4968-82E2-D4B0897B605E} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {DA015F58-BD32-48AF-848D-74DEA5E6B905} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {B1726E92-FD6E-4628-BD20-148095281E1D} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {00984992-0ED5-40F1-8821-0B6367D05968} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3B9E5B72-F580-465A-A22C-2D2148AF4EB4} From eea31f52f301b74915ebfb32b66e6fdc3b107a8e Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 03/16] Introduce base EF Core persistence abstractions --- .../Abstractions/BasePersistence.cs | 41 ++++++++++++++ .../Implementation/DataStoreBase.cs | 44 +++++++++++++++ .../Infrastructure/SequentialGuidGenerator.cs | 53 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Infrastructure/SequentialGuidGenerator.cs diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs new file mode 100644 index 0000000000..974a151788 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs @@ -0,0 +1,41 @@ +namespace ServiceControl.Persistence.Sql.Core.Abstractions; + +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; +using ServiceControl.Persistence.MessageRedirects; +using ServiceControl.Persistence.UnitOfWork; +using Implementation; +using Implementation.UnitOfWork; + +public abstract class BasePersistence +{ + protected static void RegisterDataStores(IServiceCollection services, bool maintenanceMode) + { + if (maintenanceMode) + { + return; + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs new file mode 100644 index 0000000000..38ea61b2b9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs @@ -0,0 +1,44 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Threading.Tasks; +using DbContexts; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Base class for data stores that provides helper methods to simplify scope and DbContext management +/// +public abstract class DataStoreBase +{ + protected readonly IServiceProvider serviceProvider; + + protected DataStoreBase(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + /// + /// Executes an operation with a scoped DbContext, returning a result + /// + protected async Task ExecuteWithDbContext(Func> operation) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await operation(dbContext); + } + + /// + /// Executes an operation with a scoped DbContext, without returning a result + /// + protected async Task ExecuteWithDbContext(Func operation) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await operation(dbContext); + } + + /// + /// Creates a scope for operations that need to manage their own scope lifecycle (e.g., managers) + /// + protected IServiceScope CreateScope() => serviceProvider.CreateScope(); +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Infrastructure/SequentialGuidGenerator.cs b/src/ServiceControl.Persistence.Sql.Core/Infrastructure/SequentialGuidGenerator.cs new file mode 100644 index 0000000000..e2d5ff7c29 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Infrastructure/SequentialGuidGenerator.cs @@ -0,0 +1,53 @@ +namespace ServiceControl.Persistence.Sql.Core.Infrastructure; + +using System; + +/// +/// Generates sequential GUIDs for database primary keys to minimize page fragmentation +/// and improve insert performance while maintaining security benefits of GUIDs. +/// +/// +/// This implementation creates time-ordered GUIDs similar to .NET 9's Guid.CreateVersion7() +/// but compatible with .NET 8. The GUIDs are ordered by timestamp to reduce B-tree page splits +/// in clustered indexes, which significantly improves insert performance compared to random GUIDs. +/// +/// Benefits: +/// - Database agnostic (works with SQL Server, PostgreSQL, MySQL, SQLite) +/// - Sequential ordering reduces page fragmentation +/// - Better insert performance than random GUIDs +/// - Can easily migrate to Guid.CreateVersion7() when upgrading to .NET 9+ +/// - No external dependencies +/// +/// Security: +/// - Still cryptographically secure (uses Guid.NewGuid() as base) +/// - Not guessable (unlike sequential integers) +/// - Safe to expose in APIs +/// +public static class SequentialGuidGenerator +{ + /// + /// Generate a sequential GUID with timestamp-based ordering for optimal database performance. + /// + /// A new GUID with sequential characteristics. + public static Guid NewSequentialGuid() + { + var guidBytes = Guid.NewGuid().ToByteArray(); + var now = DateTime.UtcNow; + + // Get timestamp in milliseconds since Unix epoch (similar to Version 7 GUIDs) + var timestamp = (long)(now - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + var timestampBytes = BitConverter.GetBytes(timestamp); + + // Reverse if little-endian to get big-endian byte order for proper sorting + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timestampBytes); + } + + // Replace last 6 bytes with timestamp for sequential ordering + // This placement works well with SQL Server's GUID comparison semantics + Array.Copy(timestampBytes, 2, guidBytes, 10, 6); + + return new Guid(guidBytes); + } +} From f3fa4016d950dd3272ca2bcea95f4a5306a55821 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 04/16] Add core EF Core entities and configurations --- .../Entities/ArchiveOperationEntity.cs | 19 +++++ .../Entities/CustomCheckEntity.cs | 14 ++++ .../Entities/EndpointSettingsEntity.cs | 7 ++ .../Entities/EventLogItemEntity.cs | 14 ++++ ...xternalIntegrationDispatchRequestEntity.cs | 10 +++ .../Entities/FailedErrorImportEntity.cs | 10 +++ .../Entities/FailedMessageEntity.cs | 37 ++++++++++ .../Entities/FailedMessageRetryEntity.cs | 11 +++ .../Entities/GroupCommentEntity.cs | 10 +++ .../Entities/KnownEndpointEntity.cs | 11 +++ .../Entities/MessageBodyEntity.cs | 12 ++++ .../Entities/MessageRedirectsEntity.cs | 11 +++ .../Entities/NotificationsSettingsEntity.cs | 9 +++ .../Entities/QueueAddressEntity.cs | 7 ++ .../Entities/RetryBatchEntity.cs | 23 ++++++ .../Entities/RetryBatchNowForwardingEntity.cs | 10 +++ .../Entities/RetryHistoryEntity.cs | 8 +++ .../Entities/SubscriptionEntity.cs | 9 +++ .../ArchiveOperationConfiguration.cs | 30 ++++++++ .../CustomCheckConfiguration.cs | 25 +++++++ .../EndpointSettingsConfiguration.cs | 22 ++++++ .../EventLogItemConfiguration.cs | 39 +++++++++++ ...IntegrationDispatchRequestConfiguration.cs | 23 ++++++ .../FailedErrorImportConfiguration.cs | 17 +++++ .../FailedMessageConfiguration.cs | 70 +++++++++++++++++++ .../FailedMessageRetryConfiguration.cs | 22 ++++++ .../GroupCommentConfiguration.cs | 19 +++++ .../KnownEndpointConfiguration.cs | 19 +++++ .../MessageBodyConfiguration.cs | 19 +++++ .../MessageRedirectsConfiguration.cs | 28 ++++++++ .../NotificationsSettingsConfiguration.cs | 16 +++++ .../QueueAddressConfiguration.cs | 16 +++++ .../RetryBatchConfiguration.cs | 29 ++++++++ .../RetryBatchNowForwardingConfiguration.cs | 18 +++++ .../RetryHistoryConfiguration.cs | 17 +++++ .../SubscriptionConfiguration.cs | 21 ++++++ 36 files changed, 682 insertions(+) create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/ArchiveOperationEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/CustomCheckEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/EndpointSettingsEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/ExternalIntegrationDispatchRequestEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/FailedErrorImportEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageRetryEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/GroupCommentEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/KnownEndpointEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/MessageRedirectsEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/NotificationsSettingsEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/QueueAddressEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchNowForwardingEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/RetryHistoryEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/SubscriptionEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ArchiveOperationConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/CustomCheckConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EndpointSettingsConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageRetryConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/GroupCommentConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/KnownEndpointConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/QueueAddressConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchNowForwardingConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/ArchiveOperationEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/ArchiveOperationEntity.cs new file mode 100644 index 0000000000..0ac92d6d5f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/ArchiveOperationEntity.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class ArchiveOperationEntity +{ + public Guid Id { get; set; } + public string RequestId { get; set; } = null!; + public string GroupName { get; set; } = null!; + public int ArchiveType { get; set; } // ArchiveType enum as int + public int ArchiveState { get; set; } // ArchiveState enum as int + public int TotalNumberOfMessages { get; set; } + public int NumberOfMessagesArchived { get; set; } + public int NumberOfBatches { get; set; } + public int CurrentBatch { get; set; } + public DateTime Started { get; set; } + public DateTime? Last { get; set; } + public DateTime? CompletionTime { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/CustomCheckEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/CustomCheckEntity.cs new file mode 100644 index 0000000000..0598b97e58 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/CustomCheckEntity.cs @@ -0,0 +1,14 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class CustomCheckEntity +{ + public Guid Id { get; set; } + public string CustomCheckId { get; set; } = null!; + public string? Category { get; set; } + public int Status { get; set; } // 0 = Pass, 1 = Fail + public DateTime ReportedAt { get; set; } + public string? FailureReason { get; set; } + public string EndpointName { get; set; } = null!; + public Guid HostId { get; set; } + public string Host { get; set; } = null!; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/EndpointSettingsEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/EndpointSettingsEntity.cs new file mode 100644 index 0000000000..ea88866b63 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/EndpointSettingsEntity.cs @@ -0,0 +1,7 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class EndpointSettingsEntity +{ + public required string Name { get; set; } + public bool TrackInstances { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs new file mode 100644 index 0000000000..348c0512c1 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs @@ -0,0 +1,14 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class EventLogItemEntity +{ + public Guid Id { get; set; } + public required string Description { get; set; } + public int Severity { get; set; } + public DateTime RaisedAt { get; set; } + public string? RelatedTo { get; set; } // Stored as JSON array + public string? Category { get; set; } + public string? EventType { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/ExternalIntegrationDispatchRequestEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/ExternalIntegrationDispatchRequestEntity.cs new file mode 100644 index 0000000000..6a0c50450a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/ExternalIntegrationDispatchRequestEntity.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class ExternalIntegrationDispatchRequestEntity +{ + public long Id { get; set; } + public string DispatchContextJson { get; set; } = null!; + public DateTime CreatedAt { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedErrorImportEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedErrorImportEntity.cs new file mode 100644 index 0000000000..a88d4632c3 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedErrorImportEntity.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class FailedErrorImportEntity +{ + public Guid Id { get; set; } + public string MessageJson { get; set; } = null!; // FailedTransportMessage as JSON + public string? ExceptionInfo { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs new file mode 100644 index 0000000000..bf44d025c1 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs @@ -0,0 +1,37 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; +using ServiceControl.MessageFailures; + +public class FailedMessageEntity +{ + public Guid Id { get; set; } + public string UniqueMessageId { get; set; } = null!; + public FailedMessageStatus Status { get; set; } + + // JSON columns for complex nested data + public string ProcessingAttemptsJson { get; set; } = null!; + public string FailureGroupsJson { get; set; } = null!; + + // Denormalized fields from FailureGroups for efficient filtering + // PrimaryFailureGroupId is the first group ID from FailureGroupsJson array + public string? PrimaryFailureGroupId { get; set; } + + // Denormalized fields from the last processing attempt for efficient querying + public string? MessageId { get; set; } + public string? MessageType { get; set; } + public DateTime? TimeSent { get; set; } + public string? SendingEndpointName { get; set; } + public string? ReceivingEndpointName { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? QueueAddress { get; set; } + public int? NumberOfProcessingAttempts { get; set; } + public DateTime? LastProcessedAt { get; set; } + public string? ConversationId { get; set; } + + // Performance metrics for sorting and filtering + public TimeSpan? CriticalTime { get; set; } + public TimeSpan? ProcessingTime { get; set; } + public TimeSpan? DeliveryTime { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageRetryEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageRetryEntity.cs new file mode 100644 index 0000000000..8daab62466 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageRetryEntity.cs @@ -0,0 +1,11 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class FailedMessageRetryEntity +{ + public Guid Id { get; set; } + public string FailedMessageId { get; set; } = null!; + public string? RetryBatchId { get; set; } + public int StageAttempts { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/GroupCommentEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/GroupCommentEntity.cs new file mode 100644 index 0000000000..a94c4c049b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/GroupCommentEntity.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class GroupCommentEntity +{ + public Guid Id { get; set; } + public string GroupId { get; set; } = null!; + public string Comment { get; set; } = null!; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/KnownEndpointEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/KnownEndpointEntity.cs new file mode 100644 index 0000000000..40ca68bdee --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/KnownEndpointEntity.cs @@ -0,0 +1,11 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class KnownEndpointEntity +{ + public Guid Id { get; set; } + public string EndpointName { get; set; } = null!; + public Guid HostId { get; set; } + public string Host { get; set; } = null!; + public string HostDisplayName { get; set; } = null!; + public bool Monitored { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs new file mode 100644 index 0000000000..d5d1531acc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs @@ -0,0 +1,12 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class MessageBodyEntity +{ + public Guid Id { get; set; } + public byte[] Body { get; set; } = null!; + public string ContentType { get; set; } = null!; + public int BodySize { get; set; } + public string? Etag { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/MessageRedirectsEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/MessageRedirectsEntity.cs new file mode 100644 index 0000000000..d1c9b77f38 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/MessageRedirectsEntity.cs @@ -0,0 +1,11 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class MessageRedirectsEntity +{ + public Guid Id { get; set; } + public required string ETag { get; set; } + public DateTime LastModified { get; set; } + public required string RedirectsJson { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/NotificationsSettingsEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/NotificationsSettingsEntity.cs new file mode 100644 index 0000000000..d6ede51cc1 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/NotificationsSettingsEntity.cs @@ -0,0 +1,9 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; + +public class NotificationsSettingsEntity +{ + public Guid Id { get; set; } + public string EmailSettingsJson { get; set; } = string.Empty; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/QueueAddressEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/QueueAddressEntity.cs new file mode 100644 index 0000000000..fe1302b60f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/QueueAddressEntity.cs @@ -0,0 +1,7 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class QueueAddressEntity +{ + public string PhysicalAddress { get; set; } = null!; + public int FailedMessageCount { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchEntity.cs new file mode 100644 index 0000000000..b8cff440c6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchEntity.cs @@ -0,0 +1,23 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +using System; +using ServiceControl.Persistence; + +public class RetryBatchEntity +{ + public Guid Id { get; set; } + public string? Context { get; set; } + public string RetrySessionId { get; set; } = null!; + public string? StagingId { get; set; } + public string? Originator { get; set; } + public string? Classifier { get; set; } + public DateTime StartTime { get; set; } + public DateTime? Last { get; set; } + public string RequestId { get; set; } = null!; + public int InitialBatchSize { get; set; } + public RetryType RetryType { get; set; } + public RetryBatchStatus Status { get; set; } + + // JSON column for list of retry IDs + public string FailureRetriesJson { get; set; } = "[]"; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchNowForwardingEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchNowForwardingEntity.cs new file mode 100644 index 0000000000..d93800728d --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/RetryBatchNowForwardingEntity.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class RetryBatchNowForwardingEntity +{ + public int Id { get; set; } + public string RetryBatchId { get; set; } = null!; + + // This is a singleton entity - only one forwarding batch at a time + public const int SingletonId = 1; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/RetryHistoryEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/RetryHistoryEntity.cs new file mode 100644 index 0000000000..7056f97f1a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/RetryHistoryEntity.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class RetryHistoryEntity +{ + public int Id { get; set; } = 1; // Singleton pattern + public string? HistoricOperationsJson { get; set; } + public string? UnacknowledgedOperationsJson { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/SubscriptionEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/SubscriptionEntity.cs new file mode 100644 index 0000000000..d09e7ed7d8 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/SubscriptionEntity.cs @@ -0,0 +1,9 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class SubscriptionEntity +{ + public string Id { get; set; } = null!; + public string MessageTypeTypeName { get; set; } = null!; + public int MessageTypeVersion { get; set; } + public string SubscribersJson { get; set; } = null!; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ArchiveOperationConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ArchiveOperationConfiguration.cs new file mode 100644 index 0000000000..109c5e8e32 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ArchiveOperationConfiguration.cs @@ -0,0 +1,30 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class ArchiveOperationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ArchiveOperations"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.RequestId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.GroupName).HasMaxLength(200).IsRequired(); + builder.Property(e => e.ArchiveType).IsRequired(); + builder.Property(e => e.ArchiveState).IsRequired(); + builder.Property(e => e.TotalNumberOfMessages).IsRequired(); + builder.Property(e => e.NumberOfMessagesArchived).IsRequired(); + builder.Property(e => e.NumberOfBatches).IsRequired(); + builder.Property(e => e.CurrentBatch).IsRequired(); + builder.Property(e => e.Started).IsRequired(); + builder.Property(e => e.Last); + builder.Property(e => e.CompletionTime); + + builder.HasIndex(e => e.RequestId); + builder.HasIndex(e => e.ArchiveState); + builder.HasIndex(e => new { e.ArchiveType, e.RequestId }).IsUnique(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/CustomCheckConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/CustomCheckConfiguration.cs new file mode 100644 index 0000000000..ce5861d2e2 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/CustomCheckConfiguration.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class CustomCheckConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CustomChecks"); + builder.HasKey(e => e.Id); + builder.Property(e => e.CustomCheckId).IsRequired().HasMaxLength(500); + builder.Property(e => e.Category).HasMaxLength(500); + builder.Property(e => e.Status).IsRequired(); + builder.Property(e => e.ReportedAt).IsRequired(); + builder.Property(e => e.FailureReason); + builder.Property(e => e.EndpointName).IsRequired().HasMaxLength(500); + builder.Property(e => e.HostId).IsRequired(); + builder.Property(e => e.Host).IsRequired().HasMaxLength(500); + + // Index for filtering by status + builder.HasIndex(e => e.Status); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EndpointSettingsConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EndpointSettingsConfiguration.cs new file mode 100644 index 0000000000..365fcad7f9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EndpointSettingsConfiguration.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class EndpointSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EndpointSettings"); + + builder.HasKey(e => e.Name); + + builder.Property(e => e.Name) + .IsRequired() + .HasMaxLength(500); + + builder.Property(e => e.TrackInstances) + .IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs new file mode 100644 index 0000000000..83eb012b5a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs @@ -0,0 +1,39 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class EventLogItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EventLogItems"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.Id) + .IsRequired(); + + builder.Property(e => e.Description) + .IsRequired(); + + builder.Property(e => e.Severity) + .IsRequired(); + + builder.Property(e => e.RaisedAt) + .IsRequired(); + + builder.Property(e => e.Category) + .HasMaxLength(200); + + builder.Property(e => e.EventType) + .HasMaxLength(200); + + builder.Property(e => e.RelatedTo) + .HasMaxLength(4000); + + // Index for querying by RaisedAt + builder.HasIndex(e => e.RaisedAt); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs new file mode 100644 index 0000000000..84bef66b78 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs @@ -0,0 +1,23 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class ExternalIntegrationDispatchRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ExternalIntegrationDispatchRequests"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id) + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .IsRequired(); + + builder.Property(e => e.DispatchContextJson).IsRequired(); + builder.Property(e => e.CreatedAt).IsRequired(); + + builder.HasIndex(e => e.CreatedAt); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs new file mode 100644 index 0000000000..cf1363e64c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs @@ -0,0 +1,17 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedErrorImportConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FailedErrorImports"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.MessageJson).IsRequired(); + builder.Property(e => e.ExceptionInfo); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs new file mode 100644 index 0000000000..29e62b8854 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs @@ -0,0 +1,70 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FailedMessages"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.UniqueMessageId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.Status).IsRequired(); + builder.Property(e => e.ProcessingAttemptsJson).IsRequired(); + builder.Property(e => e.FailureGroupsJson).IsRequired(); + + // Denormalized query fields from FailureGroups + builder.Property(e => e.PrimaryFailureGroupId).HasMaxLength(200); + + // Denormalized query fields from processing attempts + builder.Property(e => e.MessageId).HasMaxLength(200); + builder.Property(e => e.MessageType).HasMaxLength(500); + builder.Property(e => e.SendingEndpointName).HasMaxLength(500); + builder.Property(e => e.ReceivingEndpointName).HasMaxLength(500); + builder.Property(e => e.ExceptionType).HasMaxLength(500); + builder.Property(e => e.QueueAddress).HasMaxLength(500); + builder.Property(e => e.ConversationId).HasMaxLength(200); + + // PRIMARY: Critical for uniqueness and upserts + builder.HasIndex(e => e.UniqueMessageId).IsUnique(); + + // COMPOSITE INDEXES: Hot paths - Status is involved in most queries + // Most common pattern: Status + LastProcessedAt (15+ queries) + builder.HasIndex(e => new { e.Status, e.LastProcessedAt }); + + // Endpoint-specific queries (8+ queries) + builder.HasIndex(e => new { e.ReceivingEndpointName, e.Status, e.LastProcessedAt }); + + // Queue-specific retry operations (6+ queries) + builder.HasIndex(e => new { e.QueueAddress, e.Status, e.LastProcessedAt }); + + // Retry operations by queue (3+ queries) + builder.HasIndex(e => new { e.Status, e.QueueAddress }); + + // TIME-BASED QUERIES + // Endpoint + time range queries (for GetAllMessagesForEndpoint) + builder.HasIndex(e => new { e.ReceivingEndpointName, e.TimeSent }); + + // Conversation tracking queries + builder.HasIndex(e => new { e.ConversationId, e.LastProcessedAt }); + + // SEARCH QUERIES + // Message type + time filtering + builder.HasIndex(e => new { e.MessageType, e.TimeSent }); + + // FAILURE GROUP QUERIES + // Critical for group-based filtering (avoids loading all messages) + builder.HasIndex(e => new { e.PrimaryFailureGroupId, e.Status, e.LastProcessedAt }); + + // SINGLE-COLUMN INDEXES: Keep for specific lookup cases + builder.HasIndex(e => e.MessageId); + + // PERFORMANCE METRICS INDEXES: For sorting operations + builder.HasIndex(e => e.CriticalTime); + builder.HasIndex(e => e.ProcessingTime); + builder.HasIndex(e => e.DeliveryTime); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageRetryConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageRetryConfiguration.cs new file mode 100644 index 0000000000..3b9d3bfa63 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageRetryConfiguration.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedMessageRetryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FailedMessageRetries"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.FailedMessageId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.RetryBatchId).HasMaxLength(200); + builder.Property(e => e.StageAttempts).IsRequired(); + + // Indexes + builder.HasIndex(e => e.FailedMessageId); + builder.HasIndex(e => e.RetryBatchId); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/GroupCommentConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/GroupCommentConfiguration.cs new file mode 100644 index 0000000000..8d21c9ebc5 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/GroupCommentConfiguration.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class GroupCommentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("GroupComments"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.GroupId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.Comment).IsRequired(); + + builder.HasIndex(e => e.GroupId); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/KnownEndpointConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/KnownEndpointConfiguration.cs new file mode 100644 index 0000000000..7b4d1bf7ed --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/KnownEndpointConfiguration.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class KnownEndpointConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("KnownEndpoints"); + builder.HasKey(e => e.Id); + builder.Property(e => e.EndpointName).IsRequired().HasMaxLength(500); + builder.Property(e => e.HostId).IsRequired(); + builder.Property(e => e.Host).IsRequired().HasMaxLength(500); + builder.Property(e => e.HostDisplayName).IsRequired().HasMaxLength(500); + builder.Property(e => e.Monitored).IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs new file mode 100644 index 0000000000..3b918cbfe4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class MessageBodyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MessageBodies"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.Body).IsRequired(); + builder.Property(e => e.ContentType).HasMaxLength(200).IsRequired(); + builder.Property(e => e.BodySize).IsRequired(); + builder.Property(e => e.Etag).HasMaxLength(100); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs new file mode 100644 index 0000000000..9206b9d1de --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs @@ -0,0 +1,28 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class MessageRedirectsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MessageRedirects"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.Id) + .IsRequired(); + + builder.Property(e => e.ETag) + .IsRequired() + .HasMaxLength(200); + + builder.Property(e => e.LastModified) + .IsRequired(); + + builder.Property(e => e.RedirectsJson) + .IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs new file mode 100644 index 0000000000..ba1c34ea9c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class NotificationsSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NotificationsSettings"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.EmailSettingsJson).IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/QueueAddressConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/QueueAddressConfiguration.cs new file mode 100644 index 0000000000..b9d5776c3a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/QueueAddressConfiguration.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class QueueAddressConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("QueueAddresses"); + builder.HasKey(e => e.PhysicalAddress); + builder.Property(e => e.PhysicalAddress).HasMaxLength(500); + builder.Property(e => e.FailedMessageCount).IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs new file mode 100644 index 0000000000..e97de041be --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs @@ -0,0 +1,29 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class RetryBatchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RetryBatches"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.RetrySessionId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.RequestId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.StagingId).HasMaxLength(200); + builder.Property(e => e.Originator).HasMaxLength(500); + builder.Property(e => e.Classifier).HasMaxLength(500); + builder.Property(e => e.StartTime).IsRequired(); + builder.Property(e => e.Status).IsRequired(); + builder.Property(e => e.RetryType).IsRequired(); + builder.Property(e => e.FailureRetriesJson).IsRequired(); + + // Indexes + builder.HasIndex(e => e.RetrySessionId); + builder.HasIndex(e => e.Status); + builder.HasIndex(e => e.StagingId); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchNowForwardingConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchNowForwardingConfiguration.cs new file mode 100644 index 0000000000..dfd5a3c455 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchNowForwardingConfiguration.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class RetryBatchNowForwardingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RetryBatchNowForwarding"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.RetryBatchId).HasMaxLength(200).IsRequired(); + + builder.HasIndex(e => e.RetryBatchId); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs new file mode 100644 index 0000000000..6cc2f0625d --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs @@ -0,0 +1,17 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class RetryHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RetryHistory"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasDefaultValue(1).ValueGeneratedNever(); + builder.Property(e => e.HistoricOperationsJson); + builder.Property(e => e.UnacknowledgedOperationsJson); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs new file mode 100644 index 0000000000..3cefb5daa3 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class SubscriptionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Subscriptions"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasMaxLength(100); + builder.Property(e => e.MessageTypeTypeName).IsRequired().HasMaxLength(500); + builder.Property(e => e.MessageTypeVersion).IsRequired(); + builder.Property(e => e.SubscribersJson).IsRequired(); + + // Unique composite index to enforce one subscription per message type/version + builder.HasIndex(e => new { e.MessageTypeTypeName, e.MessageTypeVersion }).IsUnique(); + } +} From f863b9e8258a318c6f8fe907e839780474d6a3ac Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 05/16] Implement core EF Core data stores --- .../Implementation/ArchiveMessages.cs | 160 +++++++ .../Implementation/BodyStorage.cs | 39 ++ .../Implementation/CustomChecksDataStore.cs | 118 +++++ .../EditFailedMessagesManager.cs | 118 +++++ .../Implementation/EndpointSettingsStore.cs | 70 +++ .../ErrorMessageDataStore.FailureGroups.cs | 238 ++++++++++ .../ErrorMessageDataStore.MessageQueries.cs | 422 ++++++++++++++++++ .../ErrorMessageDataStore.Recoverability.cs | 172 +++++++ .../ErrorMessageDataStore.ViewMapping.cs | 178 ++++++++ .../Implementation/ErrorMessageDataStore.cs | 130 ++++++ .../Implementation/EventLogDataStore.cs | 73 +++ .../ExternalIntegrationRequestsDataStore.cs | 155 +++++++ .../FailedErrorImportDataStore.cs | 87 ++++ .../FailedMessageViewIndexNotifications.cs | 23 + .../Implementation/GroupsDataStore.cs | 134 ++++++ .../MessageRedirectsDataStore.cs | 79 ++++ .../Implementation/MonitoringDataStore.cs | 136 ++++++ .../Implementation/NotificationsManager.cs | 64 +++ .../Implementation/QueueAddressStore.cs | 65 +++ .../Implementation/RetryBatchesDataStore.cs | 97 ++++ .../Implementation/RetryBatchesManager.cs | 257 +++++++++++ .../Implementation/RetryDocumentDataStore.cs | 327 ++++++++++++++ .../Implementation/RetryHistoryDataStore.cs | 152 +++++++ .../ServiceControlSubscriptionStorage.cs | 228 ++++++++++ 24 files changed, 3522 insertions(+) create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.Recoverability.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/FailedMessageViewIndexNotifications.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs new file mode 100644 index 0000000000..3db1a5d183 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs @@ -0,0 +1,160 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DbContexts; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; + +public class ArchiveMessages : DataStoreBase, IArchiveMessages +{ + readonly IDomainEvents domainEvents; + readonly ILogger logger; + + public ArchiveMessages( + IServiceProvider serviceProvider, + IDomainEvents domainEvents, + ILogger logger) : base(serviceProvider) + { + this.domainEvents = domainEvents; + this.logger = logger; + } + + public async Task ArchiveAllInGroup(string groupId) + { + // This would update all failed messages in the group to archived status + // For now, this is a placeholder that would need the failed message infrastructure + logger.LogInformation("Archiving all messages in group {GroupId}", groupId); + await Task.CompletedTask; + } + + public async Task UnarchiveAllInGroup(string groupId) + { + logger.LogInformation("Unarchiving all messages in group {GroupId}", groupId); + await Task.CompletedTask; + } + + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) + { + return ExecuteWithDbContext(dbContext => + { + var operationId = MakeOperationId(groupId, archiveType); + var operation = dbContext.ArchiveOperations + .AsNoTracking() + .FirstOrDefault(a => a.Id == Guid.Parse(operationId)); + + if (operation == null) + { + return Task.FromResult(false); + } + + return Task.FromResult(operation.ArchiveState != (int)ArchiveState.ArchiveCompleted); + }).Result; + } + + public bool IsArchiveInProgressFor(string groupId) + { + return IsOperationInProgressFor(groupId, ArchiveType.FailureGroup) || + IsOperationInProgressFor(groupId, ArchiveType.All); + } + + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + { + ExecuteWithDbContext(dbContext => + { + var operationId = Guid.Parse(MakeOperationId(groupId, archiveType)); + + dbContext.ArchiveOperations.Where(a => a.Id == operationId).ExecuteDelete(); + return Task.CompletedTask; + }).Wait(); + } + + public Task StartArchiving(string groupId, ArchiveType archiveType) + { + return ExecuteWithDbContext(async dbContext => + { + var operation = new ArchiveOperationEntity + { + Id = Guid.Parse(MakeOperationId(groupId, archiveType)), + RequestId = groupId, + GroupName = groupId, + ArchiveType = (int)archiveType, + ArchiveState = (int)ArchiveState.ArchiveStarted, + TotalNumberOfMessages = 0, + NumberOfMessagesArchived = 0, + NumberOfBatches = 0, + CurrentBatch = 0, + Started = DateTime.UtcNow + }; + + await dbContext.ArchiveOperations.AddAsync(operation); + await dbContext.SaveChangesAsync(); + + logger.LogInformation("Started archiving for group {GroupId}", groupId); + }); + } + + public Task StartUnarchiving(string groupId, ArchiveType archiveType) + { + return ExecuteWithDbContext(async dbContext => + { + var operation = new ArchiveOperationEntity + { + Id = Guid.Parse(MakeOperationId(groupId, archiveType)), + RequestId = groupId, + GroupName = groupId, + ArchiveType = (int)archiveType, + ArchiveState = (int)ArchiveState.ArchiveStarted, + TotalNumberOfMessages = 0, + NumberOfMessagesArchived = 0, + NumberOfBatches = 0, + CurrentBatch = 0, + Started = DateTime.UtcNow + }; + + await dbContext.ArchiveOperations.AddAsync(operation); + await dbContext.SaveChangesAsync(); + + logger.LogInformation("Started unarchiving for group {GroupId}", groupId); + }); + } + + public IEnumerable GetArchivalOperations() + { + // Note: IEnumerable methods need direct scope management as they yield results + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var operations = dbContext.ArchiveOperations + .AsNoTracking() + .AsEnumerable(); + + foreach (var op in operations) + { + yield return new InMemoryArchive(op.RequestId, (ArchiveType)op.ArchiveType, domainEvents) + { + GroupName = op.GroupName, + ArchiveState = (ArchiveState)op.ArchiveState, + TotalNumberOfMessages = op.TotalNumberOfMessages, + NumberOfMessagesArchived = op.NumberOfMessagesArchived, + NumberOfBatches = op.NumberOfBatches, + CurrentBatch = op.CurrentBatch, + Started = op.Started, + Last = op.Last, + CompletionTime = op.CompletionTime + }; + } + } + + static string MakeOperationId(string groupId, ArchiveType archiveType) + { + return $"{archiveType}/{groupId}"; + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs new file mode 100644 index 0000000000..e4151988e9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs @@ -0,0 +1,39 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using ServiceControl.Operations.BodyStorage; + +public class BodyStorage : DataStoreBase, IBodyStorage +{ + public BodyStorage(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task TryFetch(string bodyId) + { + return ExecuteWithDbContext(async dbContext => + { + // Try to fetch the body directly by ID + var messageBody = await dbContext.MessageBodies + .AsNoTracking() + .FirstOrDefaultAsync(mb => mb.Id == Guid.Parse(bodyId)); + + if (messageBody == null) + { + return new MessageBodyStreamResult { HasResult = false }; + } + + return new MessageBodyStreamResult + { + HasResult = true, + Stream = new MemoryStream(messageBody.Body), + ContentType = messageBody.ContentType, + BodySize = messageBody.BodySize, + Etag = messageBody.Etag + }; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs new file mode 100644 index 0000000000..ff02d9f381 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs @@ -0,0 +1,118 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Contracts.CustomChecks; +using Entities; +using Microsoft.EntityFrameworkCore; +using Operations; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; + +public class CustomChecksDataStore : DataStoreBase, ICustomChecksDataStore +{ + public CustomChecksDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task UpdateCustomCheckStatus(CustomCheckDetail detail) + { + return ExecuteWithDbContext(async dbContext => + { + var status = CheckStateChange.Unchanged; + var id = detail.GetDeterministicId(); + + var customCheck = await dbContext.CustomChecks.FirstOrDefaultAsync(c => c.Id == id); + + if (customCheck == null || + (customCheck.Status == (int)Status.Fail && !detail.HasFailed) || + (customCheck.Status == (int)Status.Pass && detail.HasFailed)) + { + if (customCheck == null) + { + customCheck = new CustomCheckEntity { Id = id }; + await dbContext.CustomChecks.AddAsync(customCheck); + } + + status = CheckStateChange.Changed; + } + + customCheck.CustomCheckId = detail.CustomCheckId; + customCheck.Category = detail.Category; + customCheck.Status = detail.HasFailed ? (int)Status.Fail : (int)Status.Pass; + customCheck.ReportedAt = detail.ReportedAt; + customCheck.FailureReason = detail.FailureReason; + customCheck.EndpointName = detail.OriginatingEndpoint.Name; + customCheck.HostId = detail.OriginatingEndpoint.HostId; + customCheck.Host = detail.OriginatingEndpoint.Host; + + await dbContext.SaveChangesAsync(); + + return status; + }); + } + + public Task>> GetStats(PagingInfo paging, string? status = null) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.CustomChecks.AsQueryable(); + + // Add status filter + if (status == "fail") + { + query = query.Where(c => c.Status == (int)Status.Fail); + } + if (status == "pass") + { + query = query.Where(c => c.Status == (int)Status.Pass); + } + + var totalCount = await query.CountAsync(); + + var results = await query + .OrderByDescending(c => c.ReportedAt) + .Skip(paging.Offset) + .Take(paging.Next) + .AsNoTracking() + .ToListAsync(); + + var customChecks = results.Select(e => new CustomCheck + { + Id = $"{e.Id}", + CustomCheckId = e.CustomCheckId, + Category = e.Category, + Status = (Status)e.Status, + ReportedAt = e.ReportedAt, + FailureReason = e.FailureReason, + OriginatingEndpoint = new EndpointDetails + { + Name = e.EndpointName, + HostId = e.HostId, + Host = e.Host + } + }).ToList(); + + var queryStats = new QueryStatsInfo(string.Empty, totalCount, false); + return new QueryResult>(customChecks, queryStats); + }); + } + + public Task DeleteCustomCheck(Guid id) + { + return ExecuteWithDbContext(async dbContext => + { + await dbContext.CustomChecks.Where(c => c.Id == id).ExecuteDeleteAsync(); + }); + } + + public Task GetNumberOfFailedChecks() + { + return ExecuteWithDbContext(async dbContext => + { + return await dbContext.CustomChecks.CountAsync(c => c.Status == (int)Status.Fail); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs new file mode 100644 index 0000000000..840e25897a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs @@ -0,0 +1,118 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; + +class EditFailedMessagesManager( + IServiceScope scope) : IEditFailedMessagesManager +{ + readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); + string? currentEditingRequestId; + FailedMessage? currentMessage; + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public async Task GetFailedMessage(string uniqueMessageId) + { + var entity = await dbContext.FailedMessages + .FirstOrDefaultAsync(m => m.UniqueMessageId == uniqueMessageId); + + if (entity == null) + { + return null; + } + + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonOptions) ?? []; + var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonOptions) ?? []; + + currentMessage = new FailedMessage + { + Id = entity.Id.ToString(), + UniqueMessageId = entity.UniqueMessageId, + Status = entity.Status, + ProcessingAttempts = processingAttempts, + FailureGroups = failureGroups + }; + + return currentMessage; + } + + public async Task UpdateFailedMessage(FailedMessage failedMessage) + { + var entity = await dbContext.FailedMessages + .FirstOrDefaultAsync(m => m.Id == Guid.Parse(failedMessage.Id)); + + if (entity != null) + { + entity.Status = failedMessage.Status; + entity.ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts, JsonOptions); + entity.FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups, JsonOptions); + entity.PrimaryFailureGroupId = failedMessage.FailureGroups.Count > 0 ? failedMessage.FailureGroups[0].Id : null; + + // Update denormalized fields from last attempt + var lastAttempt = failedMessage.ProcessingAttempts.LastOrDefault(); + if (lastAttempt != null) + { + entity.MessageId = lastAttempt.MessageId; + entity.MessageType = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.EnclosedMessageTypes"); + entity.TimeSent = lastAttempt.AttemptedAt; + entity.SendingEndpointName = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.OriginatingEndpoint"); + entity.ReceivingEndpointName = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.ProcessingEndpoint"); + entity.ExceptionType = lastAttempt.FailureDetails?.Exception?.ExceptionType; + entity.ExceptionMessage = lastAttempt.FailureDetails?.Exception?.Message; + entity.QueueAddress = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.FailedQ"); + entity.LastProcessedAt = lastAttempt.AttemptedAt; + + // Extract performance metrics from metadata + entity.CriticalTime = lastAttempt.MessageMetadata?.TryGetValue("CriticalTime", out var ct) == true && ct is TimeSpan ctSpan ? ctSpan : null; + entity.ProcessingTime = lastAttempt.MessageMetadata?.TryGetValue("ProcessingTime", out var pt) == true && pt is TimeSpan ptSpan ? ptSpan : null; + entity.DeliveryTime = lastAttempt.MessageMetadata?.TryGetValue("DeliveryTime", out var dt) == true && dt is TimeSpan dtSpan ? dtSpan : null; + } + + entity.NumberOfProcessingAttempts = failedMessage.ProcessingAttempts.Count; + } + } + + public Task GetCurrentEditingRequestId(string failedMessageId) + { + // Simple in-memory tracking for the editing request + return Task.FromResult(currentMessage?.Id == failedMessageId ? currentEditingRequestId : null); + } + + public Task SetCurrentEditingRequestId(string editingMessageId) + { + currentEditingRequestId = editingMessageId; + return Task.CompletedTask; + } + + public async Task SetFailedMessageAsResolved() + { + if (currentMessage != null) + { + currentMessage.Status = FailedMessageStatus.Resolved; + await UpdateFailedMessage(currentMessage); + } + } + + public async Task SaveChanges() + { + await dbContext.SaveChangesAsync(); + } + + public void Dispose() + { + scope.Dispose(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs new file mode 100644 index 0000000000..106da2441b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs @@ -0,0 +1,70 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DbContexts; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +public class EndpointSettingsStore : DataStoreBase, IEndpointSettingsStore +{ + public EndpointSettingsStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public async IAsyncEnumerable GetAllEndpointSettings() + { + // Note: IAsyncEnumerable methods need direct scope management as they yield results + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var entities = dbContext.EndpointSettings.AsNoTracking().AsAsyncEnumerable(); + + await foreach (var entity in entities) + { + yield return new EndpointSettings + { + Name = entity.Name, + TrackInstances = entity.TrackInstances + }; + } + } + + public Task UpdateEndpointSettings(EndpointSettings settings, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = new EndpointSettingsEntity + { + Name = settings.Name, + TrackInstances = settings.TrackInstances + }; + + // Use EF's change tracking for upsert + var existing = await dbContext.EndpointSettings.FindAsync([entity.Name], cancellationToken); + if (existing == null) + { + dbContext.EndpointSettings.Add(entity); + } + else + { + dbContext.EndpointSettings.Update(entity); + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + + public Task Delete(string name, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + await dbContext.EndpointSettings + .Where(e => e.Name == name) + .ExecuteDeleteAsync(cancellationToken); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs new file mode 100644 index 0000000000..4c8ed9652a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs @@ -0,0 +1,238 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +partial class ErrorMessageDataStore +{ + public Task> GetFailureGroupView(string groupId, string status, string modified) + { + return ExecuteWithDbContext(async dbContext => + { + // Query failed messages filtered by PrimaryFailureGroupId at database level + var messages = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => fm.PrimaryFailureGroupId == groupId) + .ToListAsync(); + + // Deserialize failure groups to get the primary group details + var allGroups = messages + .Select(fm => + { + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + // Take the first group (which matches PrimaryFailureGroupId == groupId) + var primaryGroup = groups.FirstOrDefault(); + return new + { + Group = primaryGroup, + MessageId = fm.Id, + LastProcessedAt = fm.LastProcessedAt ?? DateTime.MinValue + }; + }) + .Where(x => x.Group != null) + .ToList(); + + if (!allGroups.Any()) + { + return new QueryResult(null!, new QueryStatsInfo("0", 0, false)); + } + + // Aggregate the group data + var firstGroup = allGroups.First().Group!; // Safe: allGroups is filtered to non-null Groups + + // Retrieve comment if exists + var commentEntity = await dbContext.GroupComments + .AsNoTracking() + .FirstOrDefaultAsync(gc => gc.GroupId == groupId); + + var view = new FailureGroupView + { + Id = groupId, + Title = firstGroup.Title, + Type = firstGroup.Type, + Count = allGroups.Count, + Comment = commentEntity?.Comment ?? string.Empty, + First = allGroups.Min(x => x.LastProcessedAt), + Last = allGroups.Max(x => x.LastProcessedAt) + }; + + return new QueryResult(view, new QueryStatsInfo("1", 1, false)); + }); + } + + public Task> GetFailureGroupsByClassifier(string classifier) + { + return ExecuteWithDbContext(async dbContext => + { + // Query all failed messages - optimize by selecting only required columns + // Note: Cannot filter by PrimaryFailureGroupId since we're filtering by classifier (Type) + var messages = await dbContext.FailedMessages + .AsNoTracking() + .Select(fm => new { fm.FailureGroupsJson, fm.LastProcessedAt }) + .ToListAsync(); + + // Deserialize and group by failure group ID + var groupedData = messages + .SelectMany(fm => + { + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + return groups.Select(g => new + { + Group = g, + LastProcessedAt = fm.LastProcessedAt ?? DateTime.MinValue + }); + }) + .Where(x => x.Group.Type == classifier) + .GroupBy(x => x.Group.Id) + .Select(g => new FailureGroupView + { + Id = g.Key, + Title = g.First().Group.Title, + Type = g.First().Group.Type, + Count = g.Count(), + Comment = string.Empty, + First = g.Min(x => x.LastProcessedAt), + Last = g.Max(x => x.LastProcessedAt) + }) + .OrderByDescending(g => g.Last) + .ToList(); + + return (IList)groupedData; + }); + } + + public Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo) + { + return ExecuteWithDbContext(async dbContext => + { + // Get messages filtered by PrimaryFailureGroupId at database level + var allMessages = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => fm.PrimaryFailureGroupId == groupId) + .ToListAsync(); + + var matchingMessages = allMessages + .Where(fm => + { + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + return groups.Any(g => g.Id == groupId); + }) + .ToList(); + + // Apply status filter if provided + if (!string.IsNullOrEmpty(status)) + { + var statusEnum = Enum.Parse(status, true); + matchingMessages = [.. matchingMessages.Where(fm => fm.Status == statusEnum)]; + } + + var totalCount = matchingMessages.Count; + + // Apply sorting (simplified - would need full sorting implementation) + matchingMessages = [.. matchingMessages + .OrderByDescending(fm => fm.LastProcessedAt) + .Skip(pagingInfo.Offset) + .Take(pagingInfo.Next)]; + + var results = matchingMessages.Select(CreateFailedMessageView).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task GetGroupErrorsCount(string groupId, string status, string modified) + { + return ExecuteWithDbContext(async dbContext => + { + var allMessages = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => fm.PrimaryFailureGroupId == groupId) + .ToListAsync(); + + var count = allMessages + .Count(fm => + { + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + var hasGroup = groups.Any(g => g.Id == groupId); + + if (!hasGroup) + { + return false; + } + + if (!string.IsNullOrEmpty(status)) + { + var statusEnum = Enum.Parse(status, true); + return fm.Status == statusEnum; + } + + return true; + }); + + return new QueryStatsInfo(count.ToString(), count, false); + }); + } + + public async Task>> GetGroup(string groupId, string status, string modified) + { + // This appears to be similar to GetFailureGroupView but returns a list + var singleResult = await GetFailureGroupView(groupId, status, modified); + + if (singleResult.Results == null) + { + return new QueryResult>([], new QueryStatsInfo("0", 0, false)); + } + + return new QueryResult>([singleResult.Results], singleResult.QueryStats); + } + + public Task EditComment(string groupId, string comment) + { + return ExecuteWithDbContext(async dbContext => + { + var commentEntity = new GroupCommentEntity + { + Id = Guid.Parse(groupId), + GroupId = groupId, + Comment = comment + }; + + // Use EF's change tracking for upsert + var existing = await dbContext.GroupComments.FindAsync(commentEntity.Id); + if (existing == null) + { + dbContext.GroupComments.Add(commentEntity); + } + else + { + dbContext.GroupComments.Update(commentEntity); + } + + await dbContext.SaveChangesAsync(); + }); + } + + public Task DeleteComment(string groupId) + { + return ExecuteWithDbContext(async dbContext => + { + var comment = await dbContext.GroupComments + .FirstOrDefaultAsync(gc => gc.GroupId == groupId); + + if (comment != null) + { + dbContext.GroupComments.Remove(comment); + await dbContext.SaveChangesAsync(); + } + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs new file mode 100644 index 0000000000..a04fb4c57a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs @@ -0,0 +1,422 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using CompositeViews.Messages; +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Persistence.Infrastructure; + +partial class ErrorMessageDataStore +{ + public Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages.AsQueryable(); + + // Apply time range filter + if (timeSentRange != null) + { + if (timeSentRange.From.HasValue) + { + query = query.Where(fm => fm.TimeSent >= timeSentRange.From); + } + if (timeSentRange.To.HasValue) + { + query = query.Where(fm => fm.TimeSent <= timeSentRange.To); + } + } + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + + var results = entities.Select(entity => CreateMessagesView(entity)).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages + .Where(fm => fm.ReceivingEndpointName == endpointName); + + // Apply time range filter + if (timeSentRange != null) + { + if (timeSentRange.From.HasValue) + { + query = query.Where(fm => fm.TimeSent >= timeSentRange.From); + } + if (timeSentRange.To.HasValue) + { + query = query.Where(fm => fm.TimeSent <= timeSentRange.To); + } + } + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + + var results = entities.Select(entity => CreateMessagesView(entity)).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages + .Where(fm => fm.ConversationId == conversationId); + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + var results = entities.Select(CreateMessagesView).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages.AsQueryable(); + + // Apply search filter + if (!string.IsNullOrWhiteSpace(searchTerms)) + { + query = query.Where(fm => + fm.MessageType!.Contains(searchTerms) || + fm.ExceptionMessage!.Contains(searchTerms) || + fm.UniqueMessageId.Contains(searchTerms)); + } + + // Apply time range filter + if (timeSentRange != null) + { + if (timeSentRange.From.HasValue) + { + query = query.Where(fm => fm.TimeSent >= timeSentRange.From); + } + if (timeSentRange.To.HasValue) + { + query = query.Where(fm => fm.TimeSent <= timeSentRange.To); + } + } + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + + var results = entities.Select(entity => CreateMessagesView(entity)).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages + .Where(fm => fm.ReceivingEndpointName == endpointName); + + // Apply search filter + if (!string.IsNullOrWhiteSpace(searchKeyword)) + { + query = query.Where(fm => + fm.MessageType!.Contains(searchKeyword) || + fm.ExceptionMessage!.Contains(searchKeyword) || + fm.UniqueMessageId.Contains(searchKeyword)); + } + + // Apply time range filter + if (timeSentRange != null) + { + if (timeSentRange.From.HasValue) + { + query = query.Where(fm => fm.TimeSent >= timeSentRange.From); + } + if (timeSentRange.To.HasValue) + { + query = query.Where(fm => fm.TimeSent <= timeSentRange.To); + } + } + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + + var results = entities.Select(entity => CreateMessagesView(entity)).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages.AsQueryable(); + + // Apply status filter + if (!string.IsNullOrWhiteSpace(status)) + { + if (Enum.TryParse(status, true, out var statusEnum)) + { + query = query.Where(fm => fm.Status == statusEnum); + } + } + + // Apply queue address filter + if (!string.IsNullOrWhiteSpace(queueAddress)) + { + query = query.Where(fm => fm.QueueAddress == queueAddress); + } + + // Apply modified date filter + if (!string.IsNullOrWhiteSpace(modified)) + { + if (DateTime.TryParse(modified, out var modifiedDate)) + { + query = query.Where(fm => fm.LastProcessedAt >= modifiedDate); + } + } + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + + var results = entities.Select(entity => CreateFailedMessageView(entity)).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task ErrorsHead(string status, string modified, string queueAddress) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages.AsQueryable(); + + // Apply status filter + if (!string.IsNullOrWhiteSpace(status)) + { + if (Enum.TryParse(status, true, out var statusEnum)) + { + query = query.Where(fm => fm.Status == statusEnum); + } + } + + // Apply queue address filter + if (!string.IsNullOrWhiteSpace(queueAddress)) + { + query = query.Where(fm => fm.QueueAddress == queueAddress); + } + + // Apply modified date filter + if (!string.IsNullOrWhiteSpace(modified)) + { + if (DateTime.TryParse(modified, out var modifiedDate)) + { + query = query.Where(fm => fm.LastProcessedAt >= modifiedDate); + } + } + + var totalCount = await query.CountAsync(); + + return new QueryStatsInfo(totalCount.ToString(), totalCount, false); + }); + } + + public Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages.AsQueryable(); + + // Apply endpoint filter + query = query.Where(fm => fm.ReceivingEndpointName == endpointName); + + // Apply status filter + if (!string.IsNullOrWhiteSpace(status)) + { + if (Enum.TryParse(status, true, out var statusEnum)) + { + query = query.Where(fm => fm.Status == statusEnum); + } + } + + // Apply modified date filter + if (!string.IsNullOrWhiteSpace(modified)) + { + if (DateTime.TryParse(modified, out var modifiedDate)) + { + query = query.Where(fm => fm.LastProcessedAt >= modifiedDate); + } + } + + var totalCount = await query.CountAsync(); + + // Apply sorting + query = ApplySorting(query, sortInfo); + + // Apply paging + query = query.Skip(pagingInfo.Offset).Take(pagingInfo.Next); + + var entities = await query.AsNoTracking().ToListAsync(); + + var results = entities.Select(entity => CreateFailedMessageView(entity)).ToList(); + + return new QueryResult>(results, new QueryStatsInfo(totalCount.ToString(), totalCount, false)); + }); + } + + public Task> ErrorsSummary() + { + return ExecuteWithDbContext(async dbContext => + { + var endpointStats = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => !string.IsNullOrEmpty(fm.ReceivingEndpointName)) + .GroupBy(fm => fm.ReceivingEndpointName) + .Select(g => new { Endpoint = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Endpoint!, x => (object)x.Count); + + var messageTypeStats = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => !string.IsNullOrEmpty(fm.MessageType)) + .GroupBy(fm => fm.MessageType) + .Select(g => new { MessageType = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.MessageType!, x => (object)x.Count); + + var hostStats = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => !string.IsNullOrEmpty(fm.QueueAddress)) + .GroupBy(fm => fm.QueueAddress) + .Select(g => new { Host = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Host!, x => (object)x.Count); + + return (IDictionary)new Dictionary + { + ["Endpoints"] = endpointStats, + ["Message types"] = messageTypeStats, + ["Hosts"] = hostStats + }; + }); + } + + public Task ErrorLastBy(string failedMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.FailedMessages + .AsNoTracking() + .FirstOrDefaultAsync(fm => fm.Id == Guid.Parse(failedMessageId)); + + if (entity == null) + { + return null!; + } + + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? []; + var lastAttempt = processingAttempts.LastOrDefault(); + + if (lastAttempt == null) + { + return null!; + } + + return new FailedMessageView + { + Id = entity.UniqueMessageId, + MessageType = entity.MessageType, + TimeSent = entity.TimeSent, + IsSystemMessage = false, // Not stored in entity + Exception = lastAttempt.FailureDetails?.Exception, + MessageId = entity.MessageId, + NumberOfProcessingAttempts = entity.NumberOfProcessingAttempts ?? 0, + Status = entity.Status, + SendingEndpoint = null, // Would need to deserialize from JSON + ReceivingEndpoint = null, // Would need to deserialize from JSON + QueueAddress = entity.QueueAddress, + TimeOfFailure = lastAttempt.FailureDetails?.TimeOfFailure ?? DateTime.MinValue, + LastModified = entity.LastProcessedAt ?? DateTime.MinValue, + Edited = false, // Not implemented + EditOf = null + }; + }); + } + + public Task ErrorBy(string failedMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.FailedMessages + .AsNoTracking() + .FirstOrDefaultAsync(fm => fm.Id == Guid.Parse(failedMessageId)); + + if (entity == null) + { + return null!; + } + + return new FailedMessage + { + Id = entity.Id.ToString(), + UniqueMessageId = entity.UniqueMessageId, + Status = entity.Status, + ProcessingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? [], + FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson) ?? [] + }; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.Recoverability.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.Recoverability.cs new file mode 100644 index 0000000000..90c423687f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.Recoverability.cs @@ -0,0 +1,172 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; + +partial class ErrorMessageDataStore +{ + public Task FailedMessageMarkAsArchived(string failedMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var failedMessage = await dbContext.FailedMessages + .FirstOrDefaultAsync(fm => fm.Id == Guid.Parse(failedMessageId)); + + if (failedMessage != null) + { + failedMessage.Status = FailedMessageStatus.Archived; + await dbContext.SaveChangesAsync(); + } + }); + } + + public Task MarkMessageAsResolved(string failedMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var failedMessage = await dbContext.FailedMessages + .FirstOrDefaultAsync(fm => fm.Id == Guid.Parse(failedMessageId)); + + if (failedMessage == null) + { + return false; + } + + failedMessage.Status = FailedMessageStatus.Resolved; + await dbContext.SaveChangesAsync(); + return true; + }); + } + + public Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, string queueAddress, Func processCallback) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages + .AsNoTracking() + .Where(fm => fm.Status == FailedMessageStatus.RetryIssued && + fm.LastProcessedAt >= periodFrom && + fm.LastProcessedAt < periodTo); + + if (!string.IsNullOrWhiteSpace(queueAddress)) + { + query = query.Where(fm => fm.QueueAddress == queueAddress); + } + + var failedMessageIds = await query + .Select(fm => fm.Id) + .ToListAsync(); + + foreach (var failedMessageId in failedMessageIds) + { + await processCallback(failedMessageId.ToString()); + } + }); + } + + public Task UnArchiveMessagesByRange(DateTime from, DateTime to) + { + return ExecuteWithDbContext(async dbContext => + { + // First, get the unique message IDs that will be affected + var uniqueMessageIds = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => fm.Status == FailedMessageStatus.Archived && + fm.LastProcessedAt >= from && + fm.LastProcessedAt < to) + .Select(fm => fm.UniqueMessageId) + .ToListAsync(); + + // Then update all matching messages in a single operation + await dbContext.FailedMessages + .Where(fm => fm.Status == FailedMessageStatus.Archived && + fm.LastProcessedAt >= from && + fm.LastProcessedAt < to) + .ExecuteUpdateAsync(setters => setters.SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved)); + + return uniqueMessageIds.ToArray(); + }); + } + + public Task UnArchiveMessages(IEnumerable failedMessageIds) + { + return ExecuteWithDbContext(async dbContext => + { + // Convert string IDs to Guids for querying + var messageGuids = failedMessageIds.Select(Guid.Parse).ToList(); + + // First, get the unique message IDs that will be affected + var uniqueMessageIds = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => messageGuids.Contains(fm.Id) && fm.Status == FailedMessageStatus.Archived) + .Select(fm => fm.UniqueMessageId) + .ToListAsync(); + + // Then update all matching messages in a single operation + await dbContext.FailedMessages + .Where(fm => messageGuids.Contains(fm.Id) && fm.Status == FailedMessageStatus.Archived) + .ExecuteUpdateAsync(setters => setters.SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved)); + + return uniqueMessageIds.ToArray(); + }); + } + + public Task RevertRetry(string messageUniqueId) + { + return ExecuteWithDbContext(async dbContext => + { + // Change status back to Unresolved + var failedMessage = await dbContext.FailedMessages + .FirstOrDefaultAsync(fm => fm.UniqueMessageId == messageUniqueId); + + if (failedMessage != null) + { + failedMessage.Status = FailedMessageStatus.Unresolved; + await dbContext.SaveChangesAsync(); + } + }); + } + + public Task RemoveFailedMessageRetryDocument(string uniqueMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var retryDocumentId = $"FailedMessages/{uniqueMessageId}"; + var retryDocument = await dbContext.FailedMessageRetries + .FirstOrDefaultAsync(r => r.FailedMessageId == retryDocumentId); + + if (retryDocument != null) + { + dbContext.FailedMessageRetries.Remove(retryDocument); + await dbContext.SaveChangesAsync(); + } + }); + } + + public Task GetRetryPendingMessages(DateTime from, DateTime to, string queueAddress) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages + .AsNoTracking() + .Where(fm => fm.Status == FailedMessageStatus.RetryIssued && + fm.LastProcessedAt >= from && + fm.LastProcessedAt < to); + + if (!string.IsNullOrWhiteSpace(queueAddress)) + { + query = query.Where(fm => fm.QueueAddress == queueAddress); + } + + var messageIds = await query + .Select(fm => fm.UniqueMessageId) + .ToListAsync(); + + return messageIds.ToArray(); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs new file mode 100644 index 0000000000..c0eb24c6a1 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs @@ -0,0 +1,178 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using CompositeViews.Messages; +using Entities; +using MessageFailures.Api; +using NServiceBus; +using ServiceControl.MessageFailures; +using ServiceControl.Operations; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.SagaAudit; + +partial class ErrorMessageDataStore +{ + internal static IQueryable ApplySorting(IQueryable query, SortInfo sortInfo) + { + if (sortInfo == null || string.IsNullOrWhiteSpace(sortInfo.Sort)) + { + return query.OrderByDescending(fm => fm.TimeSent); + } + + var isDescending = sortInfo.Direction == "desc"; + + return sortInfo.Sort.ToLower() switch + { + "id" or "message_id" => isDescending + ? query.OrderByDescending(fm => fm.MessageId) + : query.OrderBy(fm => fm.MessageId), + "message_type" => isDescending + ? query.OrderByDescending(fm => fm.MessageType) + : query.OrderBy(fm => fm.MessageType), + "critical_time" => isDescending + ? query.OrderByDescending(fm => fm.CriticalTime) + : query.OrderBy(fm => fm.CriticalTime), + "delivery_time" => isDescending + ? query.OrderByDescending(fm => fm.DeliveryTime) + : query.OrderBy(fm => fm.DeliveryTime), + "processing_time" => isDescending + ? query.OrderByDescending(fm => fm.ProcessingTime) + : query.OrderBy(fm => fm.ProcessingTime), + "processed_at" => isDescending + ? query.OrderByDescending(fm => fm.LastProcessedAt) + : query.OrderBy(fm => fm.LastProcessedAt), + "status" => isDescending + ? query.OrderByDescending(fm => fm.Status) + : query.OrderBy(fm => fm.Status), + "time_sent" or _ => isDescending + ? query.OrderByDescending(fm => fm.TimeSent) + : query.OrderBy(fm => fm.TimeSent) + }; + } + + internal static FailedMessageView CreateFailedMessageView(FailedMessageEntity entity) + { + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? []; + var lastAttempt = processingAttempts.LastOrDefault(); + + // Extract endpoint details from metadata (stored during ingestion) + EndpointDetails? sendingEndpoint = null; + EndpointDetails? receivingEndpoint = null; + + if (lastAttempt?.MessageMetadata != null) + { + if (lastAttempt.MessageMetadata.TryGetValue("SendingEndpoint", out var sendingObj) && sendingObj is JsonElement sendingJson) + { + sendingEndpoint = JsonSerializer.Deserialize(sendingJson.GetRawText()); + } + + if (lastAttempt.MessageMetadata.TryGetValue("ReceivingEndpoint", out var receivingObj) && receivingObj is JsonElement receivingJson) + { + receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText()); + } + } + + return new FailedMessageView + { + Id = entity.UniqueMessageId, + MessageType = entity.MessageType, + TimeSent = entity.TimeSent, + IsSystemMessage = false, // Not stored in entity + Exception = lastAttempt?.FailureDetails?.Exception, + MessageId = entity.MessageId, + NumberOfProcessingAttempts = entity.NumberOfProcessingAttempts ?? 0, + Status = entity.Status, + SendingEndpoint = sendingEndpoint, + ReceivingEndpoint = receivingEndpoint, + QueueAddress = entity.QueueAddress, + TimeOfFailure = lastAttempt?.FailureDetails?.TimeOfFailure ?? DateTime.MinValue, + LastModified = entity.LastProcessedAt ?? DateTime.MinValue, + Edited = false, // Not implemented + EditOf = null + }; + } + + internal static MessagesView CreateMessagesView(FailedMessageEntity entity) + { + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? []; + var lastAttempt = processingAttempts.LastOrDefault(); + + // Extract metadata from the last processing attempt (matching RavenDB implementation) + var metadata = lastAttempt?.MessageMetadata; + + var isSystemMessage = metadata?.TryGetValue("IsSystemMessage", out var isSystem) == true && isSystem is bool b && b; + var bodySize = metadata?.TryGetValue("ContentLength", out var size) == true && size is int contentLength ? contentLength : 0; + var criticalTime = metadata?.TryGetValue("CriticalTime", out var ct) == true && ct is JsonElement ctJson && TimeSpan.TryParse(ctJson.GetString(), out var parsedCt) ? parsedCt : TimeSpan.Zero; + var processingTime = metadata?.TryGetValue("ProcessingTime", out var pt) == true && pt is JsonElement ptJson && TimeSpan.TryParse(ptJson.GetString(), out var parsedPt) ? parsedPt : TimeSpan.Zero; + var deliveryTime = metadata?.TryGetValue("DeliveryTime", out var dt) == true && dt is JsonElement dtJson && TimeSpan.TryParse(dtJson.GetString(), out var parsedDt) ? parsedDt : TimeSpan.Zero; + var messageIntent = metadata?.TryGetValue("MessageIntent", out var mi) == true && mi is JsonElement miJson && Enum.TryParse(miJson.GetString(), out var parsedMi) ? parsedMi : MessageIntent.Send; + + // Extract endpoint details from metadata (stored during ingestion) + EndpointDetails? sendingEndpoint = null; + EndpointDetails? receivingEndpoint = null; + SagaInfo? originatesFromSaga = null; + List? invokedSagas = null; + + if (metadata != null) + { + if (metadata.TryGetValue("SendingEndpoint", out var sendingObj) && sendingObj is JsonElement sendingJson) + { + sendingEndpoint = JsonSerializer.Deserialize(sendingJson.GetRawText()); + } + + if (metadata.TryGetValue("ReceivingEndpoint", out var receivingObj) && receivingObj is JsonElement receivingJson) + { + receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText()); + } + + if (metadata.TryGetValue("OriginatesFromSaga", out var sagaObj) && sagaObj is JsonElement sagaJson) + { + originatesFromSaga = JsonSerializer.Deserialize(sagaJson.GetRawText()); + } + + if (metadata.TryGetValue("InvokedSagas", out var sagasObj) && sagasObj is JsonElement sagasJson) + { + invokedSagas = JsonSerializer.Deserialize>(sagasJson.GetRawText()); + } + } + + // Calculate status matching RavenDB logic + var status = entity.Status == FailedMessageStatus.Resolved + ? MessageStatus.ResolvedSuccessfully + : entity.Status == FailedMessageStatus.RetryIssued + ? MessageStatus.RetryIssued + : entity.Status == FailedMessageStatus.Archived + ? MessageStatus.ArchivedFailure + : entity.NumberOfProcessingAttempts == 1 + ? MessageStatus.Failed + : MessageStatus.RepeatedFailure; + + return new MessagesView + { + Id = entity.UniqueMessageId, + MessageId = entity.MessageId, + MessageType = entity.MessageType, + SendingEndpoint = sendingEndpoint, + ReceivingEndpoint = receivingEndpoint, + TimeSent = entity.TimeSent, + ProcessedAt = entity.LastProcessedAt ?? DateTime.MinValue, + CriticalTime = criticalTime, + ProcessingTime = processingTime, + DeliveryTime = deliveryTime, + IsSystemMessage = isSystemMessage, + ConversationId = entity.ConversationId, + Headers = lastAttempt?.Headers?.Select(h => new KeyValuePair(h.Key, h.Value)) ?? [], + Status = status, + MessageIntent = messageIntent, + BodyUrl = $"/api/errors/{entity.UniqueMessageId}/body", + BodySize = bodySize, + InvokedSagas = invokedSagas ?? [], + OriginatesFromSaga = originatesFromSaga, + InstanceId = null // Not available for failed messages + }; + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs new file mode 100644 index 0000000000..f71400903b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -0,0 +1,130 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.EventLog; +using ServiceControl.MessageFailures; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +partial class ErrorMessageDataStore : DataStoreBase, IErrorMessageDataStore +{ + public ErrorMessageDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task FailedMessagesFetch(Guid[] ids) + { + return ExecuteWithDbContext(async dbContext => + { + var entities = await dbContext.FailedMessages + .AsNoTracking() + .Where(fm => ids.Contains(fm.Id)) + .ToListAsync(); + + return entities.Select(entity => new FailedMessage + { + Id = entity.Id.ToString(), + UniqueMessageId = entity.UniqueMessageId, + Status = entity.Status, + ProcessingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? [], + FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson) ?? [] + }).ToArray(); + }); + } + + public Task StoreFailedErrorImport(FailedErrorImport failure) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = new FailedErrorImportEntity + { + Id = Guid.Parse(failure.Id), + MessageJson = JsonSerializer.Serialize(failure.Message), + ExceptionInfo = failure.ExceptionInfo + }; + + dbContext.FailedErrorImports.Add(entity); + await dbContext.SaveChangesAsync(); + }); + } + + public Task CreateEditFailedMessageManager() + { + var scope = serviceProvider.CreateScope(); + var manager = new EditFailedMessagesManager(scope); + return Task.FromResult(manager); + } + + public Task CreateNotificationsManager() + { + var notificationsManager = serviceProvider.GetRequiredService(); + return Task.FromResult(notificationsManager); + } + + public async Task StoreEventLogItem(EventLogItem logItem) + { + using var scope = serviceProvider.CreateScope(); + var eventLogDataStore = scope.ServiceProvider.GetRequiredService(); + await eventLogDataStore.Add(logItem); + } + + public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) + { + return ExecuteWithDbContext(async dbContext => + { + foreach (var failedMessage in failedMessages) + { + var lastAttempt = failedMessage.ProcessingAttempts.LastOrDefault(); + + var entity = new FailedMessageEntity + { + Id = Guid.Parse(failedMessage.Id), + UniqueMessageId = failedMessage.UniqueMessageId, + Status = failedMessage.Status, + ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts), + FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups), + PrimaryFailureGroupId = failedMessage.FailureGroups.Count > 0 ? failedMessage.FailureGroups[0].Id : null, + + // Extract denormalized fields from last processing attempt if available + MessageId = lastAttempt?.MessageId, + MessageType = lastAttempt?.Headers?.GetValueOrDefault("NServiceBus.EnclosedMessageTypes"), + TimeSent = lastAttempt?.Headers != null && lastAttempt.Headers.TryGetValue("NServiceBus.TimeSent", out var ts) && DateTimeOffset.TryParse(ts, out var parsedTime) ? parsedTime.UtcDateTime : null, + SendingEndpointName = lastAttempt?.Headers?.GetValueOrDefault("NServiceBus.OriginatingEndpoint"), + ReceivingEndpointName = lastAttempt?.Headers?.GetValueOrDefault("NServiceBus.ProcessingEndpoint"), + ExceptionType = lastAttempt?.FailureDetails?.Exception?.ExceptionType, + ExceptionMessage = lastAttempt?.FailureDetails?.Exception?.Message, + QueueAddress = lastAttempt?.FailureDetails?.AddressOfFailingEndpoint, + NumberOfProcessingAttempts = failedMessage.ProcessingAttempts.Count, + LastProcessedAt = lastAttempt?.AttemptedAt, + ConversationId = lastAttempt?.Headers?.GetValueOrDefault("NServiceBus.ConversationId"), + CriticalTime = lastAttempt?.MessageMetadata?.TryGetValue("CriticalTime", out var ct) == true && ct is TimeSpan ctSpan ? ctSpan : null, + ProcessingTime = lastAttempt?.MessageMetadata?.TryGetValue("ProcessingTime", out var pt) == true && pt is TimeSpan ptSpan ? ptSpan : null, + DeliveryTime = lastAttempt?.MessageMetadata?.TryGetValue("DeliveryTime", out var dt) == true && dt is TimeSpan dtSpan ? dtSpan : null + }; + + dbContext.FailedMessages.Add(entity); + } + + await dbContext.SaveChangesAsync(); + }); + } + + public Task FetchFromFailedMessage(string uniqueMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var messageBody = await dbContext.MessageBodies + .AsNoTracking() + .FirstOrDefaultAsync(mb => mb.Id == Guid.Parse(uniqueMessageId)); + + return messageBody?.Body!; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs new file mode 100644 index 0000000000..29a3c1577e --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs @@ -0,0 +1,73 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Infrastructure; +using Microsoft.EntityFrameworkCore; +using ServiceControl.EventLog; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; + +public class EventLogDataStore : DataStoreBase, IEventLogDataStore +{ + public EventLogDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task Add(EventLogItem logItem) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = new EventLogItemEntity + { + Id = SequentialGuidGenerator.NewSequentialGuid(), + Description = logItem.Description, + Severity = (int)logItem.Severity, + RaisedAt = logItem.RaisedAt, + Category = logItem.Category, + EventType = logItem.EventType, + RelatedTo = logItem.RelatedTo != null ? JsonSerializer.Serialize(logItem.RelatedTo) : null + }; + + await dbContext.EventLogItems.AddAsync(entity); + await dbContext.SaveChangesAsync(); + }); + } + + public Task<(IList items, long total, string version)> GetEventLogItems(PagingInfo pagingInfo) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.EventLogItems + .AsNoTracking() + .OrderByDescending(e => e.RaisedAt); + + var total = await query.CountAsync(); + + var entities = await query + .Skip(pagingInfo.Offset) + .Take(pagingInfo.PageSize) + .ToListAsync(); + + var items = entities.Select(entity => new EventLogItem + { + Id = entity.Id.ToString(), + Description = entity.Description, + Severity = (Severity)entity.Severity, + RaisedAt = entity.RaisedAt, + Category = entity.Category, + EventType = entity.EventType, + RelatedTo = entity.RelatedTo != null ? JsonSerializer.Deserialize>(entity.RelatedTo) : null + }).ToList(); + + // Version could be based on the latest RaisedAt timestamp but the paging can affect this result, given that the latest may not be retrieved + var version = entities.Any() ? entities.Max(e => e.RaisedAt).Ticks.ToString() : "0"; + + return ((IList)items, (long)total, version); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs new file mode 100644 index 0000000000..9d1b8d37ab --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs @@ -0,0 +1,155 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ServiceControl.ExternalIntegrations; +using ServiceControl.Persistence; + +public class ExternalIntegrationRequestsDataStore : DataStoreBase, IExternalIntegrationRequestsDataStore, IAsyncDisposable +{ + readonly ILogger logger; + readonly CancellationTokenSource tokenSource = new(); + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + Func? callback; + Task? dispatcherTask; + bool isDisposed; + + public ExternalIntegrationRequestsDataStore( + IServiceProvider serviceProvider, + ILogger logger) : base(serviceProvider) + { + this.logger = logger; + } + + public Task StoreDispatchRequest(IEnumerable dispatchRequests) + { + return ExecuteWithDbContext(async dbContext => + { + foreach (var dispatchRequest in dispatchRequests) + { + if (dispatchRequest.Id != null) + { + throw new ArgumentException("Items cannot have their Id property set"); + } + + var entity = new ExternalIntegrationDispatchRequestEntity + { + DispatchContextJson = JsonSerializer.Serialize(dispatchRequest.DispatchContext, JsonOptions), + CreatedAt = DateTime.UtcNow + }; + + await dbContext.ExternalIntegrationDispatchRequests.AddAsync(entity); + } + + await dbContext.SaveChangesAsync(); + }); + } + + public void Subscribe(Func callback) + { + if (this.callback != null) + { + throw new InvalidOperationException("Subscription already exists."); + } + + this.callback = callback ?? throw new ArgumentNullException(nameof(callback)); + + // Start the dispatcher task if not already running + dispatcherTask ??= DispatcherLoop(tokenSource.Token); + } + + async Task DispatcherLoop(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await DispatchBatch(cancellationToken); + + // Wait before checking for more events + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Error dispatching external integration events"); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Expected during shutdown + } + } + + async Task DispatchBatch(CancellationToken cancellationToken) + { + await ExecuteWithDbContext(async dbContext => + { + var batchSize = 100; // Default batch size + var requests = await dbContext.ExternalIntegrationDispatchRequests + .OrderBy(r => r.CreatedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + + if (requests.Count == 0) + { + return; + } + + var contexts = requests + .Select(r => JsonSerializer.Deserialize(r.DispatchContextJson, JsonOptions)!) + .ToArray(); + + logger.LogDebug("Dispatching {EventCount} events", contexts.Length); + + if (callback != null) + { + await callback(contexts); + } + + // Remove dispatched requests + dbContext.ExternalIntegrationDispatchRequests.RemoveRange(requests); + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + + public async Task StopAsync(CancellationToken cancellationToken) => await DisposeAsync(); + + public async ValueTask DisposeAsync() + { + if (isDisposed) + { + return; + } + + isDisposed = true; + await tokenSource.CancelAsync(); + + if (dispatcherTask != null) + { + await dispatcherTask; + } + + tokenSource?.Dispose(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs new file mode 100644 index 0000000000..e9e272cfbe --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs @@ -0,0 +1,87 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Diagnostics; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +public class FailedErrorImportDataStore : DataStoreBase, IFailedErrorImportDataStore +{ + readonly ILogger logger; + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public FailedErrorImportDataStore(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider) + { + this.logger = logger; + } + + public Task ProcessFailedErrorImports(Func processMessage, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var succeeded = 0; + var failed = 0; + + var imports = dbContext.FailedErrorImports.AsAsyncEnumerable(); + + await foreach (var import in imports.WithCancellation(cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + FailedTransportMessage? transportMessage = null; + try + { + transportMessage = JsonSerializer.Deserialize(import.MessageJson, JsonOptions); + + Debug.Assert(transportMessage != null, "Deserialized transport message should not be null"); + + await processMessage(transportMessage); + + dbContext.FailedErrorImports.Remove(import); + await dbContext.SaveChangesAsync(cancellationToken); + + succeeded++; + logger.LogDebug("Successfully re-imported failed error message {MessageId}", transportMessage.Id); + } + catch (OperationCanceledException e) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation(e, "Cancelled"); + break; + } + catch (Exception e) + { + logger.LogError(e, "Error while attempting to re-import failed error message {MessageId}", transportMessage?.Id ?? "unknown"); + failed++; + } + } + + logger.LogInformation("Done re-importing failed errors. Successfully re-imported {SucceededCount} messages. Failed re-importing {FailedCount} messages", succeeded, failed); + + if (failed > 0) + { + logger.LogWarning("{FailedCount} messages could not be re-imported. This could indicate a problem with the data. Contact Particular support if you need help with recovering the messages", failed); + } + }); + } + + public Task QueryContainsFailedImports() + { + return ExecuteWithDbContext(async dbContext => + { + return await dbContext.FailedErrorImports.AnyAsync(); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedMessageViewIndexNotifications.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedMessageViewIndexNotifications.cs new file mode 100644 index 0000000000..cab28eb0d6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedMessageViewIndexNotifications.cs @@ -0,0 +1,23 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Threading.Tasks; +using ServiceControl.Persistence; + +public class FailedMessageViewIndexNotifications : IFailedMessageViewIndexNotifications +{ + public IDisposable Subscribe(Func callback) + { + // For SQL persistence, we don't have real-time index change notifications + // like RavenDB does. The callback would need to be triggered manually + // when failed message data changes. For now, return a no-op disposable. + return new NoOpDisposable(); + } + + class NoOpDisposable : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs new file mode 100644 index 0000000000..8f6466cef7 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs @@ -0,0 +1,134 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +public class GroupsDataStore : DataStoreBase, IGroupsDataStore +{ + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public GroupsDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter) + { + return ExecuteWithDbContext(async dbContext => + { + // Query failed messages with unresolved status to build failure group views + var failedMessages = await dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == FailedMessageStatus.Unresolved) + .Select(m => new + { + m.FailureGroupsJson, + m.LastProcessedAt + }) + .ToListAsync(); + + // Deserialize and flatten failure groups + var allGroups = failedMessages + .SelectMany(m => + { + var groups = JsonSerializer.Deserialize>(m.FailureGroupsJson, JsonOptions) ?? []; + return groups.Select(g => new { Group = g, ProcessedAt = m.LastProcessedAt }); + }) + .Where(x => x.Group.Type == classifier) + .ToList(); + + // Apply classifier filter if specified + if (!string.IsNullOrWhiteSpace(classifierFilter)) + { + allGroups = allGroups.Where(x => x.Group.Title == classifierFilter).ToList(); + } + + // Group and aggregate + var groupViews = allGroups + .GroupBy(x => x.Group.Id) + .Select(g => new + { + g.First().Group, + Count = g.Count(), + First = g.Min(x => x.ProcessedAt) ?? DateTime.UtcNow, + Last = g.Max(x => x.ProcessedAt) ?? DateTime.UtcNow + }) + .OrderByDescending(x => x.Last) + .Take(200) + .ToList(); + + // Load comments for these groups + var groupIds = groupViews.Select(g => g.Group.Id).ToList(); + var commentLookup = await dbContext.GroupComments + .AsNoTracking() + .Where(c => groupIds.Contains(c.GroupId)) + .ToDictionaryAsync(c => c.GroupId, c => c.Comment); + + // Build result + var result = groupViews.Select(g => new FailureGroupView + { + Id = g.Group.Id, + Title = g.Group.Title, + Type = g.Group.Type, + Count = g.Count, + First = g.First, + Last = g.Last, + Comment = commentLookup.GetValueOrDefault(g.Group.Id) + }).ToList(); + + return (IList)result; + }); + } + + public Task GetCurrentForwardingBatch() + { + return ExecuteWithDbContext(async dbContext => + { + var nowForwarding = await dbContext.RetryBatchNowForwarding + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == RetryBatchNowForwardingEntity.SingletonId); + + if (nowForwarding == null || string.IsNullOrEmpty(nowForwarding.RetryBatchId)) + { + return null; + } + + var batchEntity = await dbContext.RetryBatches + .AsNoTracking() + .FirstOrDefaultAsync(b => b.Id == Guid.Parse(nowForwarding.RetryBatchId)); + + if (batchEntity == null) + { + return null; + } + + return new RetryBatch + { + Id = batchEntity.Id.ToString(), + Context = batchEntity.Context, + RetrySessionId = batchEntity.RetrySessionId, + RequestId = batchEntity.RequestId, + StagingId = batchEntity.StagingId, + Originator = batchEntity.Originator, + Classifier = batchEntity.Classifier, + StartTime = batchEntity.StartTime, + Last = batchEntity.Last, + InitialBatchSize = batchEntity.InitialBatchSize, + Status = batchEntity.Status, + RetryType = batchEntity.RetryType, + FailureRetries = JsonSerializer.Deserialize>(batchEntity.FailureRetriesJson, JsonOptions) ?? [] + }; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs new file mode 100644 index 0000000000..e44b4c8536 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs @@ -0,0 +1,79 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using ServiceControl.Persistence.MessageRedirects; + +public class MessageRedirectsDataStore : DataStoreBase, IMessageRedirectsDataStore +{ + public MessageRedirectsDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task GetOrCreate() + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.MessageRedirects + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == Guid.Parse(MessageRedirectsCollection.DefaultId)); + + if (entity == null) + { + return new MessageRedirectsCollection + { + ETag = Guid.NewGuid().ToString(), + LastModified = DateTime.UtcNow, + Redirects = [] + }; + } + + var redirects = JsonSerializer.Deserialize>(entity.RedirectsJson) ?? []; + + return new MessageRedirectsCollection + { + ETag = entity.ETag, + LastModified = entity.LastModified, + Redirects = redirects + }; + }); + } + + public Task Save(MessageRedirectsCollection redirects) + { + return ExecuteWithDbContext(async dbContext => + { + var redirectsJson = JsonSerializer.Serialize(redirects.Redirects); + var newETag = Guid.NewGuid().ToString(); + var newLastModified = DateTime.UtcNow; + + var entity = new MessageRedirectsEntity + { + Id = Guid.Parse(MessageRedirectsCollection.DefaultId), + ETag = newETag, + LastModified = newLastModified, + RedirectsJson = redirectsJson + }; + + // Use EF's change tracking for upsert + var existing = await dbContext.MessageRedirects.FindAsync(entity.Id); + if (existing == null) + { + dbContext.MessageRedirects.Add(entity); + } + else + { + dbContext.MessageRedirects.Update(entity); + } + + await dbContext.SaveChangesAsync(); + + // Update the collection with the new ETag and LastModified + redirects.ETag = newETag; + redirects.LastModified = newLastModified; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs new file mode 100644 index 0000000000..2f448fb364 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs @@ -0,0 +1,136 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +public class MonitoringDataStore : DataStoreBase, IMonitoringDataStore +{ + public MonitoringDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task CreateIfNotExists(EndpointDetails endpoint) + { + return ExecuteWithDbContext(async dbContext => + { + var id = endpoint.GetDeterministicId(); + + var exists = await dbContext.KnownEndpoints.AnyAsync(e => e.Id == id); + if (exists) + { + return; + } + + var knownEndpoint = new KnownEndpointEntity + { + Id = id, + EndpointName = endpoint.Name, + HostId = endpoint.HostId, + Host = endpoint.Host, + HostDisplayName = endpoint.Host, + Monitored = false + }; + + await dbContext.KnownEndpoints.AddAsync(knownEndpoint); + await dbContext.SaveChangesAsync(); + }); + } + + public Task CreateOrUpdate(EndpointDetails endpoint, IEndpointInstanceMonitoring endpointInstanceMonitoring) + { + return ExecuteWithDbContext(async dbContext => + { + var id = endpoint.GetDeterministicId(); + + var knownEndpoint = await dbContext.KnownEndpoints.FirstOrDefaultAsync(e => e.Id == id); + + if (knownEndpoint == null) + { + knownEndpoint = new KnownEndpointEntity + { + Id = id, + EndpointName = endpoint.Name, + HostId = endpoint.HostId, + Host = endpoint.Host, + HostDisplayName = endpoint.Host, + Monitored = true + }; + await dbContext.KnownEndpoints.AddAsync(knownEndpoint); + } + else + { + knownEndpoint.Monitored = endpointInstanceMonitoring.IsMonitored(id); + } + + await dbContext.SaveChangesAsync(); + }); + } + + public Task UpdateEndpointMonitoring(EndpointDetails endpoint, bool isMonitored) + { + return ExecuteWithDbContext(async dbContext => + { + var id = endpoint.GetDeterministicId(); + + await dbContext.KnownEndpoints + .Where(e => e.Id == id) + .ExecuteUpdateAsync(setters => setters.SetProperty(e => e.Monitored, isMonitored)); + }); + } + + public Task WarmupMonitoringFromPersistence(IEndpointInstanceMonitoring endpointInstanceMonitoring) + { + return ExecuteWithDbContext(async dbContext => + { + var endpoints = await dbContext.KnownEndpoints.AsNoTracking().ToListAsync(); + + foreach (var endpoint in endpoints) + { + var endpointDetails = new EndpointDetails + { + Name = endpoint.EndpointName, + HostId = endpoint.HostId, + Host = endpoint.Host + }; + + endpointInstanceMonitoring.DetectEndpointFromPersistentStore(endpointDetails, endpoint.Monitored); + } + }); + } + + public Task Delete(Guid endpointId) + { + return ExecuteWithDbContext(async dbContext => + { + await dbContext.KnownEndpoints + .Where(e => e.Id == endpointId) + .ExecuteDeleteAsync(); + }); + } + + public Task> GetAllKnownEndpoints() + { + return ExecuteWithDbContext(async dbContext => + { + var entities = await dbContext.KnownEndpoints.AsNoTracking().ToListAsync(); + + return (IReadOnlyList)entities.Select(e => new KnownEndpoint + { + EndpointDetails = new EndpointDetails + { + Name = e.EndpointName, + HostId = e.HostId, + Host = e.Host + }, + HostDisplayName = e.HostDisplayName, + Monitored = e.Monitored + }).ToList(); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs new file mode 100644 index 0000000000..f99fa7d2e7 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Notifications; +using ServiceControl.Persistence; + +class NotificationsManager(IServiceScope scope) : INotificationsManager +{ + readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public async Task LoadSettings(TimeSpan? cacheTimeout = null) + { + var entity = await dbContext.NotificationsSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == Guid.Parse(NotificationsSettings.SingleDocumentId)); + + if (entity == null) + { + // Return default settings if none exist + return new NotificationsSettings + { + Id = NotificationsSettings.SingleDocumentId, + Email = new EmailNotifications() + }; + } + + var emailSettings = JsonSerializer.Deserialize(entity.EmailSettingsJson, JsonOptions) ?? new EmailNotifications(); + + return new NotificationsSettings + { + Id = entity.Id.ToString(), + Email = emailSettings + }; + } + + public async Task GetUnresolvedCount() + { + return await dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == ServiceControl.MessageFailures.FailedMessageStatus.Unresolved) + .CountAsync(); + } + + public async Task SaveChanges() + { + await dbContext.SaveChangesAsync(); + } + + public void Dispose() + { + scope.Dispose(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs new file mode 100644 index 0000000000..f3c66766ba --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs @@ -0,0 +1,65 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; + +public class QueueAddressStore : DataStoreBase, IQueueAddressStore +{ + public QueueAddressStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task>> GetAddresses(PagingInfo pagingInfo) + { + return ExecuteWithDbContext(async dbContext => + { + var totalCount = await dbContext.QueueAddresses.CountAsync(); + + var addresses = await dbContext.QueueAddresses + .OrderBy(q => q.PhysicalAddress) + .Skip(pagingInfo.Offset) + .Take(pagingInfo.Next) + .AsNoTracking() + .Select(q => new QueueAddress + { + PhysicalAddress = q.PhysicalAddress, + FailedMessageCount = q.FailedMessageCount + }) + .ToListAsync(); + + var queryStats = new QueryStatsInfo(string.Empty, totalCount, false); + return new QueryResult>(addresses, queryStats); + }); + } + + public Task>> GetAddressesBySearchTerm(string search, PagingInfo pagingInfo) + { + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.QueueAddresses + .Where(q => EF.Functions.Like(q.PhysicalAddress, $"{search}%")); + + var totalCount = await query.CountAsync(); + + var addresses = await query + .OrderBy(q => q.PhysicalAddress) + .Skip(pagingInfo.Offset) + .Take(pagingInfo.Next) + .AsNoTracking() + .Select(q => new QueueAddress + { + PhysicalAddress = q.PhysicalAddress, + FailedMessageCount = q.FailedMessageCount + }) + .ToListAsync(); + + var queryStats = new QueryStatsInfo(string.Empty, totalCount, false); + return new QueryResult>(addresses, queryStats); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs new file mode 100644 index 0000000000..a806a63100 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs @@ -0,0 +1,97 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +public class RetryBatchesDataStore : DataStoreBase, IRetryBatchesDataStore +{ + readonly ILogger logger; + + public RetryBatchesDataStore( + IServiceProvider serviceProvider, + ILogger logger) : base(serviceProvider) + { + this.logger = logger; + } + + public Task CreateRetryBatchesManager() + { + var scope = CreateScope(); + return Task.FromResult( + new RetryBatchesManager(scope, logger)); + } + + public Task RecordFailedStagingAttempt( + IReadOnlyCollection messages, + IReadOnlyDictionary failedMessageRetriesById, + Exception e, + int maxStagingAttempts, + string stagingId) + { + return ExecuteWithDbContext(async dbContext => + { + foreach (var failedMessage in messages) + { + var failedMessageRetry = failedMessageRetriesById[failedMessage.Id]; + + logger.LogWarning(e, "Attempt 1 of {MaxStagingAttempts} to stage a retry message {UniqueMessageId} failed", + maxStagingAttempts, failedMessage.UniqueMessageId); + + var entity = await dbContext.FailedMessageRetries + .FirstOrDefaultAsync(f => f.Id == Guid.Parse(failedMessageRetry.Id)); + + if (entity != null) + { + entity.StageAttempts = 1; + } + } + + try + { + await dbContext.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + logger.LogDebug("Ignoring concurrency exception while incrementing staging attempt count for {StagingId}", + stagingId); + } + }); + } + + public Task IncrementAttemptCounter(FailedMessageRetry message) + { + return ExecuteWithDbContext(async dbContext => + { + try + { + await dbContext.FailedMessageRetries + .Where(f => f.Id == Guid.Parse(message.Id)) + .ExecuteUpdateAsync(setters => setters.SetProperty(f => f.StageAttempts, f => f.StageAttempts + 1)); + } + catch (DbUpdateConcurrencyException) + { + logger.LogDebug("Ignoring concurrency exception while incrementing staging attempt count for {MessageId}", + message.FailedMessageId); + } + }); + } + + public Task DeleteFailedMessageRetry(string uniqueMessageId) + { + return ExecuteWithDbContext(async dbContext => + { + var documentId = FailedMessageRetry.MakeDocumentId(uniqueMessageId); + + await dbContext.FailedMessageRetries + .Where(f => f.Id == Guid.Parse(documentId)) + .ExecuteDeleteAsync(); + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs new file mode 100644 index 0000000000..5a08a846f9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs @@ -0,0 +1,257 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using DbContexts; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.MessageRedirects; +using ServiceControl.Recoverability; + +class RetryBatchesManager( + IServiceScope scope, + ILogger logger) : IRetryBatchesManager +{ + readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); + readonly List deferredActions = []; + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Delete(RetryBatch retryBatch) + { + deferredActions.Add(() => + { + var entity = dbContext.RetryBatches.Local.FirstOrDefault(e => e.Id == Guid.Parse(retryBatch.Id)); + if (entity == null) + { + entity = new RetryBatchEntity { Id = Guid.Parse(retryBatch.Id) }; + dbContext.RetryBatches.Attach(entity); + } + dbContext.RetryBatches.Remove(entity); + }); + } + + public void Delete(RetryBatchNowForwarding forwardingBatch) + { + deferredActions.Add(() => + { + var entity = dbContext.RetryBatchNowForwarding.Local.FirstOrDefault(e => e.Id == RetryBatchNowForwardingEntity.SingletonId); + if (entity == null) + { + entity = new RetryBatchNowForwardingEntity { Id = RetryBatchNowForwardingEntity.SingletonId }; + dbContext.RetryBatchNowForwarding.Attach(entity); + } + dbContext.RetryBatchNowForwarding.Remove(entity); + }); + } + + public async Task GetFailedMessageRetries(IList stagingBatchFailureRetries) + { + var retryGuids = stagingBatchFailureRetries.Select(Guid.Parse).ToList(); + var entities = await dbContext.FailedMessageRetries + .AsNoTracking() + .Where(e => retryGuids.Contains(e.Id)) + .ToArrayAsync(); + + return entities.Select(ToFailedMessageRetry).ToArray(); + } + + public void Evict(FailedMessageRetry failedMessageRetry) + { + var entity = dbContext.FailedMessageRetries.Local.FirstOrDefault(e => e.Id == Guid.Parse(failedMessageRetry.Id)); + if (entity != null) + { + dbContext.Entry(entity).State = EntityState.Detached; + } + } + + public async Task GetFailedMessages(Dictionary.KeyCollection keys) + { + var messageGuids = keys.Select(Guid.Parse).ToList(); + var entities = await dbContext.FailedMessages + .AsNoTracking() + .Where(e => messageGuids.Contains(e.Id)) + .ToArrayAsync(); + + return entities.Select(ToFailedMessage).ToArray(); + } + + public async Task GetRetryBatchNowForwarding() + { + var entity = await dbContext.RetryBatchNowForwarding + .FirstOrDefaultAsync(e => e.Id == RetryBatchNowForwardingEntity.SingletonId); + + if (entity == null) + { + return null; + } + + // Pre-load the related retry batch for the "Include" pattern + if (!string.IsNullOrEmpty(entity.RetryBatchId)) + { + await dbContext.RetryBatches + .FirstOrDefaultAsync(b => b.Id == Guid.Parse(entity.RetryBatchId)); + } + + return new RetryBatchNowForwarding + { + RetryBatchId = entity.RetryBatchId + }; + } + + public async Task GetRetryBatch(string retryBatchId, CancellationToken cancellationToken) + { + var entity = await dbContext.RetryBatches + .FirstOrDefaultAsync(e => e.Id == Guid.Parse(retryBatchId), cancellationToken); + + return entity != null ? ToRetryBatch(entity) : null; + } + + public async Task GetStagingBatch() + { + var entity = await dbContext.RetryBatches + .FirstOrDefaultAsync(b => b.Status == RetryBatchStatus.Staging); + + if (entity == null) + { + return null; + } + + // Pre-load the related failure retries for the "Include" pattern + var failureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonOptions) ?? []; + if (failureRetries.Count > 0) + { + var retryGuids = failureRetries.Select(Guid.Parse).ToList(); + await dbContext.FailedMessageRetries + .AsNoTracking() + .Where(f => retryGuids.Contains(f.Id)) + .ToListAsync(); + } + + return ToRetryBatch(entity); + } + + public async Task Store(RetryBatchNowForwarding retryBatchNowForwarding) + { + var entity = await dbContext.RetryBatchNowForwarding + .FirstOrDefaultAsync(e => e.Id == RetryBatchNowForwardingEntity.SingletonId); + + if (entity == null) + { + entity = new RetryBatchNowForwardingEntity + { + Id = RetryBatchNowForwardingEntity.SingletonId, + RetryBatchId = retryBatchNowForwarding.RetryBatchId + }; + await dbContext.RetryBatchNowForwarding.AddAsync(entity); + } + else + { + entity.RetryBatchId = retryBatchNowForwarding.RetryBatchId; + } + } + + public async Task GetOrCreateMessageRedirectsCollection() + { + var entity = await dbContext.MessageRedirects + .FirstOrDefaultAsync(e => e.Id == Guid.Parse(MessageRedirectsCollection.DefaultId)); + + if (entity != null) + { + var collection = JsonSerializer.Deserialize(entity.RedirectsJson, JsonOptions) + ?? new MessageRedirectsCollection(); + + // Set metadata properties (ETag and LastModified are not available in EF Core the same way as RavenDB) + // We'll use a timestamp approach instead + collection.LastModified = entity.LastModified; + + return collection; + } + + return new MessageRedirectsCollection(); + } + + public Task CancelExpiration(FailedMessage failedMessage) + { + // Expiration is handled differently in SQL - we'll implement expiration via a scheduled job + // For now, this is a no-op in the manager + logger.LogDebug("CancelExpiration called for message {MessageId} - SQL expiration managed separately", failedMessage.Id); + return Task.CompletedTask; + } + + public async Task SaveChanges() + { + // Execute any deferred delete actions + foreach (var action in deferredActions) + { + action(); + } + deferredActions.Clear(); + + await dbContext.SaveChangesAsync(); + } + + public void Dispose() + { + scope.Dispose(); + } + + static RetryBatch ToRetryBatch(RetryBatchEntity entity) + { + return new RetryBatch + { + Id = entity.Id.ToString(), + Context = entity.Context, + RetrySessionId = entity.RetrySessionId, + RequestId = entity.RequestId, + StagingId = entity.StagingId, + Originator = entity.Originator, + Classifier = entity.Classifier, + StartTime = entity.StartTime, + Last = entity.Last, + InitialBatchSize = entity.InitialBatchSize, + Status = entity.Status, + RetryType = entity.RetryType, + FailureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonOptions) ?? [] + }; + } + + static FailedMessageRetry ToFailedMessageRetry(FailedMessageRetryEntity entity) + { + return new FailedMessageRetry + { + Id = entity.Id.ToString(), + FailedMessageId = entity.FailedMessageId, + RetryBatchId = entity.RetryBatchId, + StageAttempts = entity.StageAttempts + }; + } + + static FailedMessage ToFailedMessage(FailedMessageEntity entity) + { + // This is a simplified conversion - we'll need to expand this when implementing IErrorMessageDataStore + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonOptions) ?? []; + var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonOptions) ?? []; + + return new FailedMessage + { + Id = entity.Id.ToString(), + UniqueMessageId = entity.UniqueMessageId, + Status = entity.Status, + ProcessingAttempts = processingAttempts, + FailureGroups = failureGroups + }; + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs new file mode 100644 index 0000000000..dd080a97d4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs @@ -0,0 +1,327 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +public class RetryDocumentDataStore : DataStoreBase, IRetryDocumentDataStore +{ + readonly ILogger logger; + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public RetryDocumentDataStore( + IServiceProvider serviceProvider, + ILogger logger) : base(serviceProvider) + { + this.logger = logger; + } + + public Task StageRetryByUniqueMessageIds(string batchDocumentId, string[] messageIds) + { + return ExecuteWithDbContext(async dbContext => + { + foreach (var messageId in messageIds) + { + var retryId = FailedMessageRetry.MakeDocumentId(messageId); + var existing = await dbContext.FailedMessageRetries.FindAsync(Guid.Parse(retryId)); + + if (existing == null) + { + // Create new retry document + var newRetry = new FailedMessageRetryEntity + { + Id = Guid.Parse(retryId), + FailedMessageId = $"FailedMessages/{messageId}", + RetryBatchId = batchDocumentId, + StageAttempts = 0 + }; + await dbContext.FailedMessageRetries.AddAsync(newRetry); + } + else + { + // Update existing retry document + existing.FailedMessageId = $"FailedMessages/{messageId}"; + existing.RetryBatchId = batchDocumentId; + } + } + + await dbContext.SaveChangesAsync(); + }); + } + + public Task MoveBatchToStaging(string batchDocumentId) + { + return ExecuteWithDbContext(async dbContext => + { + try + { + var batch = await dbContext.RetryBatches.FirstOrDefaultAsync(b => b.Id == Guid.Parse(batchDocumentId)); + if (batch != null) + { + batch.Status = RetryBatchStatus.Staging; + await dbContext.SaveChangesAsync(); + } + } + catch (DbUpdateConcurrencyException) + { + logger.LogDebug("Ignoring concurrency exception while moving batch to staging {BatchDocumentId}", batchDocumentId); + } + }); + } + + public Task CreateBatchDocument( + string retrySessionId, + string requestId, + RetryType retryType, + string[] failedMessageRetryIds, + string originator, + DateTime startTime, + DateTime? last = null, + string? batchName = null, + string? classifier = null) + { + return ExecuteWithDbContext(async dbContext => + { + var batchDocumentId = RetryBatch.MakeDocumentId(Guid.NewGuid().ToString()); + + var batch = new RetryBatchEntity + { + Id = Guid.Parse(batchDocumentId), + Context = batchName, + RequestId = requestId, + RetryType = retryType, + Originator = originator, + Classifier = classifier, + StartTime = startTime, + Last = last, + InitialBatchSize = failedMessageRetryIds.Length, + RetrySessionId = retrySessionId, + FailureRetriesJson = JsonSerializer.Serialize(failedMessageRetryIds, JsonOptions), + Status = RetryBatchStatus.MarkingDocuments + }; + + await dbContext.RetryBatches.AddAsync(batch); + await dbContext.SaveChangesAsync(); + + return batchDocumentId; + }); + } + + public Task>> QueryOrphanedBatches(string retrySessionId) + { + return ExecuteWithDbContext(async dbContext => + { + var orphanedBatches = await dbContext.RetryBatches + .Where(b => b.Status == RetryBatchStatus.MarkingDocuments && b.RetrySessionId != retrySessionId) + .AsNoTracking() + .ToListAsync(); + + var result = orphanedBatches.Select(entity => new RetryBatch + { + Id = entity.Id.ToString(), + Context = entity.Context, + RetrySessionId = entity.RetrySessionId, + RequestId = entity.RequestId, + StagingId = entity.StagingId, + Originator = entity.Originator, + Classifier = entity.Classifier, + StartTime = entity.StartTime, + Last = entity.Last, + InitialBatchSize = entity.InitialBatchSize, + Status = entity.Status, + RetryType = entity.RetryType, + FailureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonOptions) ?? [] + }).ToList(); + + return new QueryResult>(result, new QueryStatsInfo(string.Empty, result.Count, false)); + }); + } + + public Task> QueryAvailableBatches() + { + return ExecuteWithDbContext(async dbContext => + { + // Query all batches that are either Staging or Forwarding + var results = await dbContext.RetryBatches + .AsNoTracking() + .Where(b => b.Status == RetryBatchStatus.Staging || b.Status == RetryBatchStatus.Forwarding) + .GroupBy(b => new { b.RequestId, b.RetryType, b.Originator, b.Classifier }) + .Select(g => new RetryBatchGroup + { + RequestId = g.Key.RequestId, + RetryType = g.Key.RetryType, + Originator = g.Key.Originator, + Classifier = g.Key.Classifier, + HasStagingBatches = g.Any(b => b.Status == RetryBatchStatus.Staging), + HasForwardingBatches = g.Any(b => b.Status == RetryBatchStatus.Forwarding), + InitialBatchSize = g.Sum(b => b.InitialBatchSize), + StartTime = g.Min(b => b.StartTime), + Last = g.Max(b => b.Last) ?? g.Max(b => b.StartTime) + }) + .ToListAsync(); + + return (IList)results; + }); + } + + public Task GetBatchesForAll(DateTime cutoff, Func callback) + { + return ExecuteWithDbContext(async dbContext => + { + var messages = dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == FailedMessageStatus.Unresolved) + .Select(m => new + { + m.UniqueMessageId, + m.LastProcessedAt + }) + .AsAsyncEnumerable(); + + await foreach (var message in messages) + { + var timeOfFailure = message.LastProcessedAt ?? DateTime.UtcNow; + await callback(message.UniqueMessageId, timeOfFailure); + } + }); + } + + public Task GetBatchesForEndpoint(DateTime cutoff, string endpoint, Func callback) + { + return ExecuteWithDbContext(async dbContext => + { + var messages = dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == FailedMessageStatus.Unresolved && m.ReceivingEndpointName == endpoint) + .Select(m => new + { + m.UniqueMessageId, + m.LastProcessedAt + }) + .AsAsyncEnumerable(); + + await foreach (var message in messages) + { + var timeOfFailure = message.LastProcessedAt ?? DateTime.UtcNow; + await callback(message.UniqueMessageId, timeOfFailure); + } + }); + } + + public Task GetBatchesForFailedQueueAddress(DateTime cutoff, string failedQueueAddress, FailedMessageStatus status, Func callback) + { + return ExecuteWithDbContext(async dbContext => + { + var messages = dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == FailedMessageStatus.Unresolved && m.QueueAddress == failedQueueAddress && m.Status == status) + .Select(m => new + { + m.UniqueMessageId, + m.LastProcessedAt + }) + .AsAsyncEnumerable(); + + await foreach (var message in messages) + { + var timeOfFailure = message.LastProcessedAt ?? DateTime.UtcNow; + await callback(message.UniqueMessageId, timeOfFailure); + } + }); + } + + public Task GetBatchesForFailureGroup(string groupId, string groupTitle, string groupType, DateTime cutoff, Func callback) + { + return ExecuteWithDbContext(async dbContext => + { + // Query all unresolved messages and filter by group in memory (since groups are in JSON) + var messages = await dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == FailedMessageStatus.Unresolved) + .Select(m => new + { + m.UniqueMessageId, + m.LastProcessedAt, + m.FailureGroupsJson + }) + .ToListAsync(); + + foreach (var message in messages) + { + var groups = JsonSerializer.Deserialize>(message.FailureGroupsJson, JsonOptions) ?? []; + if (groups.Any(g => g.Id == groupId)) + { + var timeOfFailure = message.LastProcessedAt ?? DateTime.UtcNow; + await callback(message.UniqueMessageId, timeOfFailure); + } + } + }); + } + + public Task QueryFailureGroupViewOnGroupId(string groupId) + { + return ExecuteWithDbContext(async dbContext => + { + // Query all unresolved messages and find those with this group + var messages = await dbContext.FailedMessages + .AsNoTracking() + .Where(m => m.Status == FailedMessageStatus.Unresolved) + .Select(m => new + { + m.FailureGroupsJson, + m.LastProcessedAt + }) + .ToListAsync(); + + FailedMessage.FailureGroup? matchingGroup = null; + var matchingMessages = new List(); + + foreach (var message in messages) + { + var groups = JsonSerializer.Deserialize>(message.FailureGroupsJson, JsonOptions) ?? []; + var group = groups.FirstOrDefault(g => g.Id == groupId); + if (group != null) + { + matchingGroup ??= group; + matchingMessages.Add(message.LastProcessedAt); + } + } + + if (matchingGroup == null || matchingMessages.Count == 0) + { + return null; + } + + // Load comment + var comment = await dbContext.GroupComments + .Where(c => c.GroupId == groupId) + .Select(c => c.Comment) + .FirstOrDefaultAsync(); + + return new FailureGroupView + { + Id = matchingGroup.Id, + Title = matchingGroup.Title, + Type = matchingGroup.Type, + Count = matchingMessages.Count, + First = matchingMessages.Min() ?? DateTime.UtcNow, + Last = matchingMessages.Max() ?? DateTime.UtcNow, + Comment = comment + }; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs new file mode 100644 index 0000000000..b6960a1062 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs @@ -0,0 +1,152 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +public class RetryHistoryDataStore : DataStoreBase, IRetryHistoryDataStore +{ + const int SingletonId = 1; + + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public RetryHistoryDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task GetRetryHistory() + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.RetryHistory + .AsNoTracking() + .FirstOrDefaultAsync(rh => rh.Id == SingletonId); + + if (entity == null) + { + return null!; + } + + var historicOperations = string.IsNullOrEmpty(entity.HistoricOperationsJson) + ? [] + : JsonSerializer.Deserialize>(entity.HistoricOperationsJson, JsonOptions) ?? []; + + var unacknowledgedOperations = string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson) + ? [] + : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonOptions) ?? []; + + return new RetryHistory + { + Id = RetryHistory.MakeId(), + HistoricOperations = historicOperations, + UnacknowledgedOperations = unacknowledgedOperations + }; + }); + } + + public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, DateTime startTime, + DateTime completionTime, string originator, string classifier, bool messageFailed, + int numberOfMessagesProcessed, DateTime lastProcessed, int retryHistoryDepth) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.RetryHistory.FirstOrDefaultAsync(rh => rh.Id == SingletonId); + + if (entity == null) + { + entity = new RetryHistoryEntity { Id = SingletonId }; + await dbContext.RetryHistory.AddAsync(entity); + } + + // Deserialize existing data + var historicOperations = string.IsNullOrEmpty(entity.HistoricOperationsJson) + ? [] + : JsonSerializer.Deserialize>(entity.HistoricOperationsJson, JsonOptions) ?? []; + + var unacknowledgedOperations = string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson) + ? [] + : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonOptions) ?? []; + + // Add to history (mimicking RetryHistory.AddToHistory) + var historicOperation = new HistoricRetryOperation + { + RequestId = requestId, + RetryType = retryType, + StartTime = startTime, + CompletionTime = completionTime, + Originator = originator, + Failed = messageFailed, + NumberOfMessagesProcessed = numberOfMessagesProcessed + }; + + historicOperations = historicOperations + .Union(new[] { historicOperation }) + .OrderByDescending(retry => retry.CompletionTime) + .Take(retryHistoryDepth) + .ToList(); + + // Add to unacknowledged if applicable + if (retryType is not RetryType.MultipleMessages and not RetryType.SingleMessage) + { + var unacknowledgedOperation = new UnacknowledgedRetryOperation + { + RequestId = requestId, + RetryType = retryType, + StartTime = startTime, + CompletionTime = completionTime, + Last = lastProcessed, + Originator = originator, + Classifier = classifier, + Failed = messageFailed, + NumberOfMessagesProcessed = numberOfMessagesProcessed + }; + + unacknowledgedOperations.Add(unacknowledgedOperation); + } + + // Serialize and save + entity.HistoricOperationsJson = JsonSerializer.Serialize(historicOperations, JsonOptions); + entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonOptions); + + await dbContext.SaveChangesAsync(); + }); + } + + public Task AcknowledgeRetryGroup(string groupId) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.RetryHistory.FirstOrDefaultAsync(rh => rh.Id == SingletonId); + + if (entity == null || string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson)) + { + return false; + } + + var unacknowledgedOperations = JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonOptions) ?? []; + + // Find and remove matching operations + var removed = unacknowledgedOperations.RemoveAll(x => + x.Classifier == groupId && x.RetryType == RetryType.FailureGroup); + + if (removed > 0) + { + entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonOptions); + await dbContext.SaveChangesAsync(); + return true; + } + + return false; + }); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs new file mode 100644 index 0000000000..8eff61b361 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs @@ -0,0 +1,228 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Entities; +using Microsoft.EntityFrameworkCore; +using NServiceBus.Extensibility; +using NServiceBus.Settings; +using NServiceBus.Unicast.Subscriptions; +using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; +using ServiceControl.Infrastructure; +using ServiceControl.Persistence; + +public class ServiceControlSubscriptionStorage : DataStoreBase, IServiceControlSubscriptionStorage +{ + readonly SubscriptionClient localClient; + readonly MessageType[] locallyHandledEventTypes; + ILookup subscriptionsLookup = Enumerable.Empty().ToLookup(x => x, x => new Subscriber("", "")); + readonly SemaphoreSlim subscriptionsLock = new SemaphoreSlim(1); + + public ServiceControlSubscriptionStorage( + IServiceProvider serviceProvider, + IReadOnlySettings settings, + ReceiveAddresses receiveAddresses) + : this( + serviceProvider, + settings.EndpointName(), + receiveAddresses.MainReceiveAddress, + settings.GetAvailableTypes().Implementing().Select(e => new MessageType(e)).ToArray()) + { + } + + public ServiceControlSubscriptionStorage( + IServiceProvider serviceProvider, + string endpointName, + string localAddress, + MessageType[] locallyHandledEventTypes) : base(serviceProvider) + { + localClient = new SubscriptionClient + { + Endpoint = endpointName, + TransportAddress = localAddress + }; + this.locallyHandledEventTypes = locallyHandledEventTypes; + } + + public Task Initialize() + { + return ExecuteWithDbContext(async dbContext => + { + var subscriptions = await dbContext.Subscriptions + .AsNoTracking() + .ToListAsync(); + + UpdateLookup(subscriptions); + }); + } + + public async Task Subscribe(Subscriber subscriber, MessageType messageType, ContextBag context, CancellationToken cancellationToken) + { + if (subscriber.Endpoint == localClient.Endpoint) + { + return; + } + + try + { + await subscriptionsLock.WaitAsync(cancellationToken); + + await ExecuteWithDbContext(async dbContext => + { + var subscriptionId = FormatId(messageType); + var subscriptionClient = CreateSubscriptionClient(subscriber); + + var subscription = await dbContext.Subscriptions + .FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken); + + if (subscription == null) + { + subscription = new SubscriptionEntity + { + Id = subscriptionId, + MessageTypeTypeName = messageType.TypeName, + MessageTypeVersion = messageType.Version.Major, + SubscribersJson = JsonSerializer.Serialize(new List { subscriptionClient }) + }; + await dbContext.Subscriptions.AddAsync(subscription, cancellationToken); + } + else + { + var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson) ?? []; + if (!subscribers.Contains(subscriptionClient)) + { + subscribers.Add(subscriptionClient); + subscription.SubscribersJson = JsonSerializer.Serialize(subscribers); + } + else + { + // Already subscribed, no need to save + return; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + + // Refresh lookup + var allSubscriptions = await dbContext.Subscriptions.AsNoTracking().ToListAsync(cancellationToken); + UpdateLookup(allSubscriptions); + }); + } + finally + { + subscriptionsLock.Release(); + } + } + + public async Task Unsubscribe(Subscriber subscriber, MessageType messageType, ContextBag context, CancellationToken cancellationToken) + { + try + { + await subscriptionsLock.WaitAsync(cancellationToken); + + await ExecuteWithDbContext(async dbContext => + { + var subscriptionId = FormatId(messageType); + var subscription = await dbContext.Subscriptions + .FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken); + + if (subscription != null) + { + var subscriptionClient = CreateSubscriptionClient(subscriber); + var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson) ?? []; + + if (subscribers.Remove(subscriptionClient)) + { + subscription.SubscribersJson = JsonSerializer.Serialize(subscribers); + await dbContext.SaveChangesAsync(cancellationToken); + + // Refresh lookup + var allSubscriptions = await dbContext.Subscriptions.AsNoTracking().ToListAsync(cancellationToken); + UpdateLookup(allSubscriptions); + } + } + }); + } + finally + { + subscriptionsLock.Release(); + } + } + + public Task> GetSubscriberAddressesForMessage(IEnumerable messageTypes, ContextBag context, CancellationToken cancellationToken) + { + return Task.FromResult(messageTypes.SelectMany(x => subscriptionsLookup[x]).Distinct()); + } + + void UpdateLookup(List subscriptions) + { + subscriptionsLookup = (from subscription in subscriptions + let subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson) ?? [] + from client in subscribers + select new + { + MessageType = new MessageType(subscription.MessageTypeTypeName, new Version(subscription.MessageTypeVersion, 0)), + Subscriber = new Subscriber(client.TransportAddress, client.Endpoint) + }).Union( + from eventType in locallyHandledEventTypes + select new + { + MessageType = eventType, + Subscriber = new Subscriber(localClient.TransportAddress, localClient.Endpoint) + }).ToLookup(x => x.MessageType, x => x.Subscriber); + } + + static SubscriptionClient CreateSubscriptionClient(Subscriber subscriber) + { + //When the subscriber is running V6 and UseLegacyMessageDrivenSubscriptionMode is enabled at the subscriber the 'subcriber.Endpoint' value is null + var endpoint = subscriber.Endpoint ?? subscriber.TransportAddress.Split('@').First(); + return new SubscriptionClient + { + TransportAddress = subscriber.TransportAddress, + Endpoint = endpoint + }; + } + + string FormatId(MessageType messageType) + { + // use MD5 hash to get a 16-byte hash of the string + var inputBytes = Encoding.Default.GetBytes($"{messageType.TypeName}/{messageType.Version.Major}"); + var hashBytes = MD5.HashData(inputBytes); + + // generate a guid from the hash: + var id = new Guid(hashBytes); + return id.ToString(); + } + + class SubscriptionClient + { + public string TransportAddress { get; set; } = null!; + public string Endpoint { get; set; } = null!; + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + return obj is SubscriptionClient client && Equals(client); + } + + bool Equals(SubscriptionClient obj) => + string.Equals(TransportAddress, obj.TransportAddress, StringComparison.InvariantCultureIgnoreCase); + + public override int GetHashCode() => TransportAddress.ToLowerInvariant().GetHashCode(); + } +} From be396b1a9b60117eb0a6e2e68c6aadcb71f7f16d Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 06/16] Implement EF Core Unit of Work for ingestion --- .../UnitOfWork/IngestionUnitOfWork.cs | 33 +++ .../UnitOfWork/IngestionUnitOfWorkFactory.cs | 21 ++ .../MonitoringIngestionUnitOfWork.cs | 33 +++ .../RecoverabilityIngestionUnitOfWork.cs | 234 ++++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/MonitoringIngestionUnitOfWork.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs new file mode 100644 index 0000000000..806c4a1d07 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs @@ -0,0 +1,33 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; + +using System.Threading; +using System.Threading.Tasks; +using DbContexts; +using ServiceControl.Persistence.UnitOfWork; + +class IngestionUnitOfWork : IngestionUnitOfWorkBase +{ + public IngestionUnitOfWork(ServiceControlDbContextBase dbContext) + { + DbContext = dbContext; + Monitoring = new MonitoringIngestionUnitOfWork(this); + Recoverability = new RecoverabilityIngestionUnitOfWork(this); + } + + internal ServiceControlDbContextBase DbContext { get; } + + // EF Core automatically batches all pending operations + // The upsert operations execute SQL directly, but EF Core tracked changes (Add/Remove/Update) are batched + public override Task Complete(CancellationToken cancellationToken) => + DbContext.SaveChangesAsync(cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DbContext?.Dispose(); + } + base.Dispose(disposing); + } +} + diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs new file mode 100644 index 0000000000..f630175b2e --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; + +using System; +using System.Threading.Tasks; +using DbContexts; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; +using ServiceControl.Persistence.UnitOfWork; + +class IngestionUnitOfWorkFactory(IServiceProvider serviceProvider, MinimumRequiredStorageState storageState) : IIngestionUnitOfWorkFactory +{ + public ValueTask StartNew() + { + var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = new IngestionUnitOfWork(dbContext); + return ValueTask.FromResult(unitOfWork); + } + + public bool CanIngestMore() => storageState.CanIngestMore; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/MonitoringIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/MonitoringIngestionUnitOfWork.cs new file mode 100644 index 0000000000..1c02ed9f55 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/MonitoringIngestionUnitOfWork.cs @@ -0,0 +1,33 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; + +using System.Threading.Tasks; +using Entities; +using ServiceControl.Persistence; +using ServiceControl.Persistence.UnitOfWork; + +class MonitoringIngestionUnitOfWork(IngestionUnitOfWork parent) : IMonitoringIngestionUnitOfWork +{ + public async Task RecordKnownEndpoint(KnownEndpoint knownEndpoint) + { + var entity = new KnownEndpointEntity + { + Id = knownEndpoint.EndpointDetails.GetDeterministicId(), + EndpointName = knownEndpoint.EndpointDetails.Name, + HostId = knownEndpoint.EndpointDetails.HostId, + Host = knownEndpoint.EndpointDetails.Host, + HostDisplayName = knownEndpoint.HostDisplayName, + Monitored = knownEndpoint.Monitored + }; + + // Use EF's change tracking for upsert + var existing = await parent.DbContext.KnownEndpoints.FindAsync(entity.Id); + if (existing == null) + { + parent.DbContext.KnownEndpoints.Add(entity); + } + else + { + parent.DbContext.Entry(existing).CurrentValues.SetValues(entity); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs new file mode 100644 index 0000000000..10ae0bd635 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -0,0 +1,234 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Infrastructure; +using Microsoft.EntityFrameworkCore; +using NServiceBus; +using NServiceBus.Transport; +using ServiceControl.MessageFailures; +using ServiceControl.Operations; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Persistence.UnitOfWork; + +class RecoverabilityIngestionUnitOfWork(IngestionUnitOfWork parent) : IRecoverabilityIngestionUnitOfWork +{ + const int MaxProcessingAttempts = 10; + + public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMessage.ProcessingAttempt processingAttempt, List groups) + { + var uniqueMessageId = context.Headers.UniqueId(); + var contentType = GetContentType(context.Headers, "text/plain"); + var bodySize = context.Body.Length; + + // Add metadata to the processing attempt + processingAttempt.MessageMetadata.Add("ContentType", contentType); + processingAttempt.MessageMetadata.Add("ContentLength", bodySize); + processingAttempt.MessageMetadata.Add("BodyUrl", $"/messages/{uniqueMessageId}/body"); + + // Store endpoint details in metadata for efficient retrieval + var sendingEndpoint = ExtractSendingEndpoint(context.Headers); + var receivingEndpoint = ExtractReceivingEndpoint(context.Headers); + + if (sendingEndpoint != null) + { + processingAttempt.MessageMetadata.Add("SendingEndpoint", sendingEndpoint); + } + + if (receivingEndpoint != null) + { + processingAttempt.MessageMetadata.Add("ReceivingEndpoint", receivingEndpoint); + } + + // Extract denormalized fields from headers for efficient querying + var messageType = context.Headers.TryGetValue(Headers.EnclosedMessageTypes, out var mt) ? mt?.Split(',').FirstOrDefault()?.Trim() : null; + var timeSent = context.Headers.TryGetValue(Headers.TimeSent, out var ts) && DateTimeOffset.TryParse(ts, out var parsedTime) ? parsedTime.UtcDateTime : (DateTime?)null; + var queueAddress = context.Headers.TryGetValue("NServiceBus.FailedQ", out var qa) ? qa : null; + var conversationId = context.Headers.TryGetValue(Headers.ConversationId, out var cid) ? cid : null; + + // Extract performance metrics from metadata for efficient sorting + var criticalTime = processingAttempt.MessageMetadata.TryGetValue("CriticalTime", out var ct) && ct is TimeSpan ctSpan ? (TimeSpan?)ctSpan : null; + var processingTime = processingAttempt.MessageMetadata.TryGetValue("ProcessingTime", out var pt) && pt is TimeSpan ptSpan ? (TimeSpan?)ptSpan : null; + var deliveryTime = processingAttempt.MessageMetadata.TryGetValue("DeliveryTime", out var dt) && dt is TimeSpan dtSpan ? (TimeSpan?)dtSpan : null; + + // Load existing message to merge attempts list + var existingMessage = await parent.DbContext.FailedMessages + .AsNoTracking() + .FirstOrDefaultAsync(fm => fm.UniqueMessageId == uniqueMessageId); + + List attempts; + if (existingMessage != null) + { + // Merge with existing attempts + attempts = JsonSerializer.Deserialize>(existingMessage.ProcessingAttemptsJson) ?? []; + + // De-duplicate attempts by AttemptedAt value + var duplicateIndex = attempts.FindIndex(a => a.AttemptedAt == processingAttempt.AttemptedAt); + if (duplicateIndex < 0) + { + attempts.Add(processingAttempt); + } + + // Trim to the latest MaxProcessingAttempts + attempts = [.. attempts + .OrderBy(a => a.AttemptedAt) + .TakeLast(MaxProcessingAttempts)]; + } + else + { + // First attempt for this message + attempts = [processingAttempt]; + } + + // Build the complete entity with all fields + var failedMessageEntity = new FailedMessageEntity + { + Id = SequentialGuidGenerator.NewSequentialGuid(), + UniqueMessageId = uniqueMessageId, + Status = FailedMessageStatus.Unresolved, + ProcessingAttemptsJson = JsonSerializer.Serialize(attempts), + FailureGroupsJson = JsonSerializer.Serialize(groups), + PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, + MessageId = processingAttempt.MessageId, + MessageType = messageType, + TimeSent = timeSent, + SendingEndpointName = sendingEndpoint?.Name, + ReceivingEndpointName = receivingEndpoint?.Name, + ExceptionType = processingAttempt.FailureDetails?.Exception?.ExceptionType, + ExceptionMessage = processingAttempt.FailureDetails?.Exception?.Message, + QueueAddress = queueAddress, + NumberOfProcessingAttempts = attempts.Count, + LastProcessedAt = processingAttempt.AttemptedAt, + ConversationId = conversationId, + CriticalTime = criticalTime, + ProcessingTime = processingTime, + DeliveryTime = deliveryTime + }; + + // Use EF's change tracking for upsert + if (existingMessage != null) + { + parent.DbContext.FailedMessages.Update(failedMessageEntity); + } + else + { + parent.DbContext.FailedMessages.Add(failedMessageEntity); + } + + // Store the message body (avoid allocation if body already exists) + await StoreMessageBody(uniqueMessageId, context.Body, contentType, bodySize); + } + + public async Task RecordSuccessfulRetry(string retriedMessageUniqueId) + { + // Find the failed message by unique ID + var failedMessage = await parent.DbContext.FailedMessages + .FirstOrDefaultAsync(fm => fm.UniqueMessageId == retriedMessageUniqueId); + + if (failedMessage != null) + { + // Update its status to Resolved - EF Core tracks this change + failedMessage.Status = FailedMessageStatus.Resolved; + } + + // Remove any retry tracking document - query by UniqueMessageId instead since we no longer have the composite pattern + var failedMsg = await parent.DbContext.FailedMessages + .AsNoTracking() + .FirstOrDefaultAsync(fm => fm.UniqueMessageId == retriedMessageUniqueId); + + if (failedMsg != null) + { + var retryDocument = await parent.DbContext.FailedMessageRetries + .FirstOrDefaultAsync(r => r.FailedMessageId == failedMsg.Id.ToString()); + + if (retryDocument != null) + { + // EF Core tracks this removal + parent.DbContext.FailedMessageRetries.Remove(retryDocument); + } + } + } + + async Task StoreMessageBody(string uniqueMessageId, ReadOnlyMemory body, string contentType, int bodySize) + { + // Parse the uniqueMessageId to Guid for querying + var bodyId = Guid.Parse(uniqueMessageId); + + // Check if body already exists (bodies are immutable) + var exists = await parent.DbContext.MessageBodies + .AsNoTracking() + .AnyAsync(mb => mb.Id == bodyId); + + if (!exists) + { + // Only allocate the array if we need to store it + var bodyEntity = new MessageBodyEntity + { + Id = bodyId, + Body = body.ToArray(), // Allocation happens here, but only when needed + ContentType = contentType, + BodySize = bodySize, + Etag = Guid.NewGuid().ToString() // Generate a simple etag + }; + + // Add new message body + parent.DbContext.MessageBodies.Add(bodyEntity); + } + // If body already exists, we don't update it (it's immutable) - no allocation! + } + + static string GetContentType(IReadOnlyDictionary headers, string defaultContentType) + => headers.TryGetValue(Headers.ContentType, out var contentType) ? contentType : defaultContentType; + + static EndpointDetails? ExtractSendingEndpoint(IReadOnlyDictionary headers) + { + var endpoint = new EndpointDetails(); + + if (headers.TryGetValue("NServiceBus.OriginatingEndpoint", out var name)) + { + endpoint.Name = name; + } + + if (headers.TryGetValue("NServiceBus.OriginatingMachine", out var host)) + { + endpoint.Host = host; + } + + if (headers.TryGetValue("NServiceBus.OriginatingHostId", out var hostId) && Guid.TryParse(hostId, out var parsedHostId)) + { + endpoint.HostId = parsedHostId; + } + + return !string.IsNullOrEmpty(endpoint.Name) ? endpoint : null; + } + + static EndpointDetails? ExtractReceivingEndpoint(IReadOnlyDictionary headers) + { + var endpoint = new EndpointDetails(); + + if (headers.TryGetValue("NServiceBus.ProcessingEndpoint", out var name)) + { + endpoint.Name = name; + } + + if (headers.TryGetValue("NServiceBus.HostDisplayName", out var host)) + { + endpoint.Host = host; + } + else if (headers.TryGetValue("NServiceBus.ProcessingMachine", out var machine)) + { + endpoint.Host = machine; + } + + if (headers.TryGetValue("NServiceBus.HostId", out var hostId) && Guid.TryParse(hostId, out var parsedHostId)) + { + endpoint.HostId = parsedHostId; + } + + return !string.IsNullOrEmpty(endpoint.Name) ? endpoint : null; + } +} From 0a7b2efcc862efb7325d2161ad02c6049a348476 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 07/16] Align persistence providers with new base abstractions --- .../TrialLicenseConfiguration.cs | 1 - .../TrialLicenseDataProvider.cs | 59 +++++++++---------- .../MySqlPersistence.cs | 11 +--- .../PostgreSqlPersistence.cs | 11 +--- .../SqlServerPersistence.cs | 11 +--- 5 files changed, 33 insertions(+), 60 deletions(-) diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs index a00d3277c0..903892ba1b 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs @@ -14,7 +14,6 @@ public void Configure(EntityTypeBuilder builder) // Ensure only one row exists by using a fixed primary key builder.Property(e => e.Id) - .HasDefaultValue(1) .ValueGeneratedNever(); builder.Property(e => e.TrialEndDate) diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs index 80cdce3eae..e34664f4a6 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs @@ -1,56 +1,51 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; -using DbContexts; +using Entities; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; -public class TrialLicenseDataProvider : ITrialLicenseDataProvider +public class TrialLicenseDataProvider : DataStoreBase, ITrialLicenseDataProvider { - readonly IServiceProvider serviceProvider; const int SingletonId = 1; - public TrialLicenseDataProvider(IServiceProvider serviceProvider) + public TrialLicenseDataProvider(IServiceProvider serviceProvider) : base(serviceProvider) { - this.serviceProvider = serviceProvider; } - public async Task GetTrialEndDate(CancellationToken cancellationToken) + public Task GetTrialEndDate(CancellationToken cancellationToken) { - using var scope = serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var entity = await dbContext.TrialLicenses - .AsNoTracking() - .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.TrialLicenses + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); - return entity?.TrialEndDate; + return entity?.TrialEndDate; + }); } - public async Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken) + public Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken) { - using var scope = serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var existingEntity = await dbContext.TrialLicenses - .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); - - if (existingEntity != null) + return ExecuteWithDbContext(async dbContext => { - // Update existing - existingEntity.TrialEndDate = trialEndDate; - } - else - { - // Insert new - var newEntity = new Entities.TrialLicenseEntity + var entity = new TrialLicenseEntity { Id = SingletonId, TrialEndDate = trialEndDate }; - await dbContext.TrialLicenses.AddAsync(newEntity, cancellationToken); - } - await dbContext.SaveChangesAsync(cancellationToken); + // Use EF's change tracking for upsert + var existing = await dbContext.TrialLicenses.FindAsync([SingletonId], cancellationToken); + if (existing == null) + { + dbContext.TrialLicenses.Add(entity); + } + else + { + dbContext.TrialLicenses.Update(entity); + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); } } diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs index 08a70a5671..e34f650f09 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs @@ -2,12 +2,11 @@ namespace ServiceControl.Persistence.Sql.MySQL; using Core.Abstractions; using Core.DbContexts; -using Core.Implementation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; -class MySqlPersistence : IPersistence +class MySqlPersistence : BasePersistence, IPersistence { readonly MySqlPersisterSettings settings; @@ -19,13 +18,7 @@ public MySqlPersistence(MySqlPersisterSettings settings) public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); - - if (settings.MaintenanceMode) - { - return; - } - - services.AddSingleton(); + RegisterDataStores(services, settings.MaintenanceMode); } public void AddInstaller(IServiceCollection services) diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs index 7bbc1b9ae2..68681dcdde 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -2,12 +2,11 @@ namespace ServiceControl.Persistence.Sql.PostgreSQL; using Core.Abstractions; using Core.DbContexts; -using Core.Implementation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; -class PostgreSqlPersistence : IPersistence +class PostgreSqlPersistence : BasePersistence, IPersistence { readonly PostgreSqlPersisterSettings settings; @@ -19,13 +18,7 @@ public PostgreSqlPersistence(PostgreSqlPersisterSettings settings) public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); - - if (settings.MaintenanceMode) - { - return; - } - - services.AddSingleton(); + RegisterDataStores(services, settings.MaintenanceMode); } public void AddInstaller(IServiceCollection services) diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs index 4073bfc3ad..d9665cb898 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs @@ -2,12 +2,11 @@ namespace ServiceControl.Persistence.Sql.SqlServer; using Core.Abstractions; using Core.DbContexts; -using Core.Implementation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; -class SqlServerPersistence : IPersistence +class SqlServerPersistence : BasePersistence, IPersistence { readonly SqlServerPersisterSettings settings; @@ -19,13 +18,7 @@ public SqlServerPersistence(SqlServerPersisterSettings settings) public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); - - if (settings.MaintenanceMode) - { - return; - } - - services.AddSingleton(); + RegisterDataStores(services, settings.MaintenanceMode); } public void AddInstaller(IServiceCollection services) From 6a62aa1de79f6e51adcfb9b9d62af8c5a0a56cd4 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 08/16] Update base DbContext and editorconfig --- .../.editorconfig | 5 ++ .../DbContexts/ServiceControlDbContextBase.cs | 36 +++++++++++ .../.editorconfig | 5 ++ .../.editorconfig | 5 ++ .../PostgreSqlDbContext.cs | 60 ++++++++++++++++++- .../PostgreSqlPersistence.cs | 1 + .../.editorconfig | 5 ++ 7 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Persistence.Sql.Core/.editorconfig b/src/ServiceControl.Persistence.Sql.Core/.editorconfig index ff993b49bb..bedef15fb6 100644 --- a/src/ServiceControl.Persistence.Sql.Core/.editorconfig +++ b/src/ServiceControl.Persistence.Sql.Core/.editorconfig @@ -2,3 +2,8 @@ # Justification: ServiceControl app has no synchronization context dotnet_diagnostic.CA2007.severity = none + +# Disable style rules for auto-generated EF migrations +[**/Migrations/*.cs] +dotnet_diagnostic.IDE0065.severity = none +generated_code = true diff --git a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs index ec07bed3ee..95c5163f54 100644 --- a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -11,12 +11,48 @@ protected ServiceControlDbContextBase(DbContextOptions options) : base(options) } public DbSet TrialLicenses { get; set; } + public DbSet EndpointSettings { get; set; } + public DbSet EventLogItems { get; set; } + public DbSet MessageRedirects { get; set; } + public DbSet Subscriptions { get; set; } + public DbSet QueueAddresses { get; set; } + public DbSet KnownEndpoints { get; set; } + public DbSet CustomChecks { get; set; } + public DbSet MessageBodies { get; set; } + public DbSet RetryHistory { get; set; } + public DbSet FailedErrorImports { get; set; } + public DbSet ExternalIntegrationDispatchRequests { get; set; } + public DbSet ArchiveOperations { get; set; } + public DbSet FailedMessages { get; set; } + public DbSet RetryBatches { get; set; } + public DbSet FailedMessageRetries { get; set; } + public DbSet GroupComments { get; set; } + public DbSet RetryBatchNowForwarding { get; set; } + public DbSet NotificationsSettings { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfiguration(new TrialLicenseConfiguration()); + modelBuilder.ApplyConfiguration(new EndpointSettingsConfiguration()); + modelBuilder.ApplyConfiguration(new EventLogItemConfiguration()); + modelBuilder.ApplyConfiguration(new MessageRedirectsConfiguration()); + modelBuilder.ApplyConfiguration(new SubscriptionConfiguration()); + modelBuilder.ApplyConfiguration(new QueueAddressConfiguration()); + modelBuilder.ApplyConfiguration(new KnownEndpointConfiguration()); + modelBuilder.ApplyConfiguration(new CustomCheckConfiguration()); + modelBuilder.ApplyConfiguration(new MessageBodyConfiguration()); + modelBuilder.ApplyConfiguration(new RetryHistoryConfiguration()); + modelBuilder.ApplyConfiguration(new FailedErrorImportConfiguration()); + modelBuilder.ApplyConfiguration(new ExternalIntegrationDispatchRequestConfiguration()); + modelBuilder.ApplyConfiguration(new ArchiveOperationConfiguration()); + modelBuilder.ApplyConfiguration(new FailedMessageConfiguration()); + modelBuilder.ApplyConfiguration(new RetryBatchConfiguration()); + modelBuilder.ApplyConfiguration(new FailedMessageRetryConfiguration()); + modelBuilder.ApplyConfiguration(new GroupCommentConfiguration()); + modelBuilder.ApplyConfiguration(new RetryBatchNowForwardingConfiguration()); + modelBuilder.ApplyConfiguration(new NotificationsSettingsConfiguration()); OnModelCreatingProvider(modelBuilder); } diff --git a/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig index ff993b49bb..fc68ac3228 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig +++ b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig @@ -2,3 +2,8 @@ # Justification: ServiceControl app has no synchronization context dotnet_diagnostic.CA2007.severity = none + +# Disable style rules for auto-generated EF migrations +[Migrations/**.cs] +dotnet_diagnostic.IDE0065.severity = none +generated_code = true diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig index ff993b49bb..fc68ac3228 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig @@ -2,3 +2,8 @@ # Justification: ServiceControl app has no synchronization context dotnet_diagnostic.CA2007.severity = none + +# Disable style rules for auto-generated EF migrations +[Migrations/**.cs] +dotnet_diagnostic.IDE0065.severity = none +generated_code = true diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs index c49a0e387f..46d736d08c 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Persistence.Sql.PostgreSQL; +using System.Text; using Core.DbContexts; using Microsoft.EntityFrameworkCore; @@ -11,20 +12,73 @@ public PostgreSqlDbContext(DbContextOptions options) : base protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Apply lowercase naming convention for PostgreSQL + // Apply snake_case naming convention for PostgreSQL foreach (var entity in modelBuilder.Model.GetEntityTypes()) { - entity.SetTableName(entity.GetTableName()?.ToLowerInvariant()); + var tableName = entity.GetTableName(); + if (tableName != null) + { + entity.SetTableName(ToSnakeCase(tableName)); + } foreach (var property in entity.GetProperties()) { - property.SetColumnName(property.GetColumnName().ToLowerInvariant()); + var columnName = property.GetColumnName(); + if (columnName != null) + { + property.SetColumnName(ToSnakeCase(columnName)); + } + } + + foreach (var key in entity.GetKeys()) + { + var keyName = key.GetName(); + if (keyName != null) + { + key.SetName(ToSnakeCase(keyName)); + } + } + + foreach (var index in entity.GetIndexes()) + { + var indexName = index.GetDatabaseName(); + if (indexName != null) + { + index.SetDatabaseName(ToSnakeCase(indexName)); + } } } base.OnModelCreating(modelBuilder); } + static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + if (i > 0 && name[i - 1] != '_') + { + builder.Append('_'); + } + builder.Append(char.ToLowerInvariant(c)); + } + else + { + builder.Append(c); + } + } + return builder.ToString(); + } + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) { // PostgreSQL-specific configurations if needed diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs index 68681dcdde..4ee76b9981 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -33,6 +33,7 @@ void ConfigureDbContext(IServiceCollection services) services.AddSingleton(settings); services.AddSingleton(settings); + services.AddDbContext((serviceProvider, options) => { options.UseNpgsql(settings.ConnectionString, npgsqlOptions => diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig index ff993b49bb..fc68ac3228 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig +++ b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig @@ -2,3 +2,8 @@ # Justification: ServiceControl app has no synchronization context dotnet_diagnostic.CA2007.severity = none + +# Disable style rules for auto-generated EF migrations +[Migrations/**.cs] +dotnet_diagnostic.IDE0065.severity = none +generated_code = true From 6e761e1d05ecfd18c516ba57834f63316e95dd9b Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 09/16] Generate initial MySQL database migration --- .../20241208000000_InitialCreate.cs | 32 - .../20251214204322_InitialCreate.Designer.cs | 622 ++++++++++++++++++ .../20251214204322_InitialCreate.cs | 588 +++++++++++++++++ .../Migrations/MySqlDbContextModelSnapshot.cs | 585 +++++++++++++++- .../MySqlDbContextFactory.cs | 3 +- 5 files changed, 1795 insertions(+), 35 deletions(-) delete mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs deleted file mode 100644 index 160fcdabd5..0000000000 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20241208000000_InitialCreate.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ServiceControl.Persistence.Sql.MySQL.Migrations; - -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -/// -public partial class InitialCreate : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "TrialLicense", - columns: table => new - { - Id = table.Column(type: "int", nullable: false, defaultValue: 1), - TrialEndDate = table.Column(type: "date", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_TrialLicense", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "TrialLicense"); - } -} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs new file mode 100644 index 0000000000..550b695c8b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs @@ -0,0 +1,622 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.Sql.MySQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + [DbContext(typeof(MySqlDbContext))] + [Migration("20251214204322_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ArchiveState") + .HasColumnType("int"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("CompletionTime") + .HasColumnType("datetime(6)"); + + b.Property("CurrentBatch") + .HasColumnType("int"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Last") + .HasColumnType("datetime(6)"); + + b.Property("NumberOfBatches") + .HasColumnType("int"); + + b.Property("NumberOfMessagesArchived") + .HasColumnType("int"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Started") + .HasColumnType("datetime(6)"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ArchiveState"); + + b.HasIndex("RequestId"); + + b.HasIndex("ArchiveType", "RequestId") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("CustomCheckId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailureReason") + .HasColumnType("longtext"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("HostId") + .HasColumnType("char(36)"); + + b.Property("ReportedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("CustomChecks", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TrackInstances") + .HasColumnType("tinyint(1)"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EventType") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RaisedAt") + .HasColumnType("datetime(6)"); + + b.Property("RelatedTo") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RaisedAt"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("ExternalIntegrationDispatchRequests", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedErrorImportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ExceptionInfo") + .HasColumnType("longtext"); + + b.Property("MessageJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FailedErrorImports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CriticalTime") + .HasColumnType("time(6)"); + + b.Property("DeliveryTime") + .HasColumnType("time(6)"); + + b.Property("ExceptionMessage") + .HasColumnType("longtext"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("LastProcessedAt") + .HasColumnType("datetime(6)"); + + b.Property("MessageId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageType") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("int"); + + b.Property("PrimaryFailureGroupId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ProcessingAttemptsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ProcessingTime") + .HasColumnType("time(6)"); + + b.Property("QueueAddress") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("SendingEndpointName") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TimeSent") + .HasColumnType("datetime(6)"); + + b.Property("UniqueMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CriticalTime"); + + b.HasIndex("DeliveryTime"); + + b.HasIndex("MessageId"); + + b.HasIndex("ProcessingTime"); + + b.HasIndex("UniqueMessageId") + .IsUnique(); + + b.HasIndex("ConversationId", "LastProcessedAt"); + + b.HasIndex("MessageType", "TimeSent"); + + b.HasIndex("ReceivingEndpointName", "TimeSent"); + + b.HasIndex("Status", "LastProcessedAt"); + + b.HasIndex("Status", "QueueAddress"); + + b.HasIndex("PrimaryFailureGroupId", "Status", "LastProcessedAt"); + + b.HasIndex("QueueAddress", "Status", "LastProcessedAt"); + + b.HasIndex("ReceivingEndpointName", "Status", "LastProcessedAt"); + + b.ToTable("FailedMessages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageRetryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FailedMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RetryBatchId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("StageAttempts") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FailedMessageId"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("FailedMessageRetries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.GroupCommentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupComments", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("HostId") + .HasColumnType("char(36)"); + + b.Property("Monitored") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("KnownEndpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("BodySize") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Etag") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("MessageBodies", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageRedirectsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ETag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("LastModified") + .HasColumnType("datetime(6)"); + + b.Property("RedirectsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MessageRedirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.NotificationsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EmailSettingsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("NotificationsSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.QueueAddressEntity", b => + { + b.Property("PhysicalAddress") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailedMessageCount") + .HasColumnType("int"); + + b.HasKey("PhysicalAddress"); + + b.ToTable("QueueAddresses", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Classifier") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Context") + .HasColumnType("longtext"); + + b.Property("FailureRetriesJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("InitialBatchSize") + .HasColumnType("int"); + + b.Property("Last") + .HasColumnType("datetime(6)"); + + b.Property("Originator") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("StagingId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("StartTime") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RetrySessionId"); + + b.HasIndex("StagingId"); + + b.HasIndex("Status"); + + b.ToTable("RetryBatches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("RetryBatchId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("RetryBatchNowForwarding", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryHistoryEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("HistoricOperationsJson") + .HasColumnType("longtext"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("RetryHistory", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MessageTypeTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MessageTypeVersion") + .HasColumnType("int"); + + b.Property("SubscribersJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs new file mode 100644 index 0000000000..c6d430c761 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs @@ -0,0 +1,588 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ArchiveOperations", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RequestId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + GroupName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ArchiveType = table.Column(type: "int", nullable: false), + ArchiveState = table.Column(type: "int", nullable: false), + TotalNumberOfMessages = table.Column(type: "int", nullable: false), + NumberOfMessagesArchived = table.Column(type: "int", nullable: false), + NumberOfBatches = table.Column(type: "int", nullable: false), + CurrentBatch = table.Column(type: "int", nullable: false), + Started = table.Column(type: "datetime(6)", nullable: false), + Last = table.Column(type: "datetime(6)", nullable: true), + CompletionTime = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ArchiveOperations", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "CustomChecks", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CustomCheckId = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Category = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Status = table.Column(type: "int", nullable: false), + ReportedAt = table.Column(type: "datetime(6)", nullable: false), + FailureReason = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + EndpointName = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + HostId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Host = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_CustomChecks", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "EndpointSettings", + columns: table => new + { + Name = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + TrackInstances = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EndpointSettings", x => x.Name); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "EventLogItems", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Description = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Severity = table.Column(type: "int", nullable: false), + RaisedAt = table.Column(type: "datetime(6)", nullable: false), + RelatedTo = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Category = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + EventType = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_EventLogItems", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ExternalIntegrationDispatchRequests", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + DispatchContextJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalIntegrationDispatchRequests", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "FailedErrorImports", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + MessageJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ExceptionInfo = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_FailedErrorImports", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "FailedMessageRetries", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FailedMessageId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + RetryBatchId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + StageAttempts = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FailedMessageRetries", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "FailedMessages", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UniqueMessageId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Status = table.Column(type: "int", nullable: false), + ProcessingAttemptsJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + FailureGroupsJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + PrimaryFailureGroupId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + MessageId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + MessageType = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + TimeSent = table.Column(type: "datetime(6)", nullable: true), + SendingEndpointName = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ReceivingEndpointName = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ExceptionType = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ExceptionMessage = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + QueueAddress = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + NumberOfProcessingAttempts = table.Column(type: "int", nullable: true), + LastProcessedAt = table.Column(type: "datetime(6)", nullable: true), + ConversationId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CriticalTime = table.Column(type: "time(6)", nullable: true), + ProcessingTime = table.Column(type: "time(6)", nullable: true), + DeliveryTime = table.Column(type: "time(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FailedMessages", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "GroupComments", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + GroupId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Comment = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_GroupComments", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "KnownEndpoints", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + EndpointName = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + HostId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Host = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + HostDisplayName = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Monitored = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KnownEndpoints", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "MessageBodies", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Body = table.Column(type: "longblob", nullable: false), + ContentType = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + BodySize = table.Column(type: "int", nullable: false), + Etag = table.Column(type: "varchar(100)", maxLength: 100, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_MessageBodies", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "MessageRedirects", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ETag = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + LastModified = table.Column(type: "datetime(6)", nullable: false), + RedirectsJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_MessageRedirects", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "NotificationsSettings", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + EmailSettingsJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationsSettings", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "QueueAddresses", + columns: table => new + { + PhysicalAddress = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + FailedMessageCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_QueueAddresses", x => x.PhysicalAddress); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "RetryBatches", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Context = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + RetrySessionId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + StagingId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Originator = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Classifier = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + StartTime = table.Column(type: "datetime(6)", nullable: false), + Last = table.Column(type: "datetime(6)", nullable: true), + RequestId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + InitialBatchSize = table.Column(type: "int", nullable: false), + RetryType = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + FailureRetriesJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_RetryBatches", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "RetryBatchNowForwarding", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + RetryBatchId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_RetryBatchNowForwarding", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "RetryHistory", + columns: table => new + { + Id = table.Column(type: "int", nullable: false, defaultValue: 1), + HistoricOperationsJson = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + UnacknowledgedOperationsJson = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_RetryHistory", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + Id = table.Column(type: "varchar(100)", maxLength: 100, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MessageTypeTypeName = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MessageTypeVersion = table.Column(type: "int", nullable: false), + SubscribersJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + Id = table.Column(type: "int", nullable: false), + TrialEndDate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrialLicense", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_ArchiveState", + table: "ArchiveOperations", + column: "ArchiveState"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_ArchiveType_RequestId", + table: "ArchiveOperations", + columns: new[] { "ArchiveType", "RequestId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_RequestId", + table: "ArchiveOperations", + column: "RequestId"); + + migrationBuilder.CreateIndex( + name: "IX_CustomChecks_Status", + table: "CustomChecks", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_EventLogItems_RaisedAt", + table: "EventLogItems", + column: "RaisedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ExternalIntegrationDispatchRequests_CreatedAt", + table: "ExternalIntegrationDispatchRequests", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessageRetries_FailedMessageId", + table: "FailedMessageRetries", + column: "FailedMessageId"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessageRetries_RetryBatchId", + table: "FailedMessageRetries", + column: "RetryBatchId"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ConversationId_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "ConversationId", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_CriticalTime", + table: "FailedMessages", + column: "CriticalTime"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_DeliveryTime", + table: "FailedMessages", + column: "DeliveryTime"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_MessageId", + table: "FailedMessages", + column: "MessageId"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_MessageType_TimeSent", + table: "FailedMessages", + columns: new[] { "MessageType", "TimeSent" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_PrimaryFailureGroupId_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "PrimaryFailureGroupId", "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ProcessingTime", + table: "FailedMessages", + column: "ProcessingTime"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_QueueAddress_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "QueueAddress", "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ReceivingEndpointName_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "ReceivingEndpointName", "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ReceivingEndpointName_TimeSent", + table: "FailedMessages", + columns: new[] { "ReceivingEndpointName", "TimeSent" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_Status_QueueAddress", + table: "FailedMessages", + columns: new[] { "Status", "QueueAddress" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_UniqueMessageId", + table: "FailedMessages", + column: "UniqueMessageId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GroupComments_GroupId", + table: "GroupComments", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_RetrySessionId", + table: "RetryBatches", + column: "RetrySessionId"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_StagingId", + table: "RetryBatches", + column: "StagingId"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_Status", + table: "RetryBatches", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatchNowForwarding_RetryBatchId", + table: "RetryBatchNowForwarding", + column: "RetryBatchId"); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_MessageTypeTypeName_MessageTypeVersion", + table: "Subscriptions", + columns: new[] { "MessageTypeTypeName", "MessageTypeVersion" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveOperations"); + + migrationBuilder.DropTable( + name: "CustomChecks"); + + migrationBuilder.DropTable( + name: "EndpointSettings"); + + migrationBuilder.DropTable( + name: "EventLogItems"); + + migrationBuilder.DropTable( + name: "ExternalIntegrationDispatchRequests"); + + migrationBuilder.DropTable( + name: "FailedErrorImports"); + + migrationBuilder.DropTable( + name: "FailedMessageRetries"); + + migrationBuilder.DropTable( + name: "FailedMessages"); + + migrationBuilder.DropTable( + name: "GroupComments"); + + migrationBuilder.DropTable( + name: "KnownEndpoints"); + + migrationBuilder.DropTable( + name: "MessageBodies"); + + migrationBuilder.DropTable( + name: "MessageRedirects"); + + migrationBuilder.DropTable( + name: "NotificationsSettings"); + + migrationBuilder.DropTable( + name: "QueueAddresses"); + + migrationBuilder.DropTable( + name: "RetryBatches"); + + migrationBuilder.DropTable( + name: "RetryBatchNowForwarding"); + + migrationBuilder.DropTable( + name: "RetryHistory"); + + migrationBuilder.DropTable( + name: "Subscriptions"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs index 182e94615d..9981136d39 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -1,6 +1,9 @@ -// +// +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using ServiceControl.Persistence.Sql.MySQL; #nullable disable @@ -19,12 +22,590 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); - modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ArchiveState") + .HasColumnType("int"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("CompletionTime") + .HasColumnType("datetime(6)"); + + b.Property("CurrentBatch") + .HasColumnType("int"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Last") + .HasColumnType("datetime(6)"); + + b.Property("NumberOfBatches") + .HasColumnType("int"); + + b.Property("NumberOfMessagesArchived") + .HasColumnType("int"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Started") + .HasColumnType("datetime(6)"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ArchiveState"); + + b.HasIndex("RequestId"); + + b.HasIndex("ArchiveType", "RequestId") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("CustomCheckId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailureReason") + .HasColumnType("longtext"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("HostId") + .HasColumnType("char(36)"); + + b.Property("ReportedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("CustomChecks", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TrackInstances") + .HasColumnType("tinyint(1)"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EventType") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RaisedAt") + .HasColumnType("datetime(6)"); + + b.Property("RelatedTo") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RaisedAt"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("ExternalIntegrationDispatchRequests", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedErrorImportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ExceptionInfo") + .HasColumnType("longtext"); + + b.Property("MessageJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("FailedErrorImports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CriticalTime") + .HasColumnType("time(6)"); + + b.Property("DeliveryTime") + .HasColumnType("time(6)"); + + b.Property("ExceptionMessage") + .HasColumnType("longtext"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("LastProcessedAt") + .HasColumnType("datetime(6)"); + + b.Property("MessageId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageType") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("int"); + + b.Property("PrimaryFailureGroupId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ProcessingAttemptsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ProcessingTime") + .HasColumnType("time(6)"); + + b.Property("QueueAddress") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("SendingEndpointName") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TimeSent") + .HasColumnType("datetime(6)"); + + b.Property("UniqueMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CriticalTime"); + + b.HasIndex("DeliveryTime"); + + b.HasIndex("MessageId"); + + b.HasIndex("ProcessingTime"); + + b.HasIndex("UniqueMessageId") + .IsUnique(); + + b.HasIndex("ConversationId", "LastProcessedAt"); + + b.HasIndex("MessageType", "TimeSent"); + + b.HasIndex("ReceivingEndpointName", "TimeSent"); + + b.HasIndex("Status", "LastProcessedAt"); + + b.HasIndex("Status", "QueueAddress"); + + b.HasIndex("PrimaryFailureGroupId", "Status", "LastProcessedAt"); + + b.HasIndex("QueueAddress", "Status", "LastProcessedAt"); + + b.HasIndex("ReceivingEndpointName", "Status", "LastProcessedAt"); + + b.ToTable("FailedMessages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageRetryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FailedMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RetryBatchId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("StageAttempts") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FailedMessageId"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("FailedMessageRetries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.GroupCommentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupComments", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("HostId") + .HasColumnType("char(36)"); + + b.Property("Monitored") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("KnownEndpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("BodySize") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Etag") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("MessageBodies", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageRedirectsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ETag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("LastModified") + .HasColumnType("datetime(6)"); + + b.Property("RedirectsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MessageRedirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.NotificationsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EmailSettingsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("NotificationsSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.QueueAddressEntity", b => + { + b.Property("PhysicalAddress") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailedMessageCount") + .HasColumnType("int"); + + b.HasKey("PhysicalAddress"); + + b.ToTable("QueueAddresses", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Classifier") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Context") + .HasColumnType("longtext"); + + b.Property("FailureRetriesJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("InitialBatchSize") + .HasColumnType("int"); + + b.Property("Last") + .HasColumnType("datetime(6)"); + + b.Property("Originator") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("StagingId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("StartTime") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RetrySessionId"); + + b.HasIndex("StagingId"); + + b.HasIndex("Status"); + + b.ToTable("RetryBatches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("RetryBatchId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("RetryBatchNowForwarding", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryHistoryEntity", b => { b.Property("Id") .HasColumnType("int") .HasDefaultValue(1); + b.Property("HistoricOperationsJson") + .HasColumnType("longtext"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("RetryHistory", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MessageTypeTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MessageTypeVersion") + .HasColumnType("int"); + + b.Property("SubscribersJson") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + b.Property("TrialEndDate") .HasColumnType("date"); diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs index 539612142d..d5215fa7f9 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs @@ -12,7 +12,8 @@ public MySqlDbContext CreateDbContext(string[] args) // Use a dummy connection string for design-time operations var connectionString = "Server=localhost;Database=servicecontrol;User=root;Password=mysql"; - optionsBuilder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); + // Use a fixed server version for design-time to avoid connection attempts + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 21))); return new MySqlDbContext(optionsBuilder.Options); } From fed1ce4b1a97fa96dc873c3555b1c35d488eb1d1 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 10/16] Generate initial PostgreSQL database migration --- .../20241208000000_InitialCreate.cs | 32 - .../20251214204335_InitialCreate.Designer.cs | 745 ++++++++++++++++++ .../20251214204335_InitialCreate.cs | 513 ++++++++++++ .../PostgreSqlDbContextModelSnapshot.cs | 714 ++++++++++++++++- 4 files changed, 1966 insertions(+), 38 deletions(-) delete mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs deleted file mode 100644 index b830a81b63..0000000000 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20241208000000_InitialCreate.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations; - -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -/// -public partial class InitialCreate : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "triallicense", - columns: table => new - { - id = table.Column(type: "integer", nullable: false, defaultValue: 1), - trialenddate = table.Column(type: "date", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_triallicense", x => x.id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "triallicense"); - } -} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs new file mode 100644 index 0000000000..1b835c69ee --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs @@ -0,0 +1,745 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ServiceControl.Persistence.Sql.PostgreSQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + [DbContext(typeof(PostgreSqlDbContext))] + [Migration("20251214204335_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArchiveState") + .HasColumnType("integer") + .HasColumnName("archive_state"); + + b.Property("ArchiveType") + .HasColumnType("integer") + .HasColumnName("archive_type"); + + b.Property("CompletionTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("completion_time"); + + b.Property("CurrentBatch") + .HasColumnType("integer") + .HasColumnName("current_batch"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("group_name"); + + b.Property("Last") + .HasColumnType("timestamp with time zone") + .HasColumnName("last"); + + b.Property("NumberOfBatches") + .HasColumnType("integer") + .HasColumnName("number_of_batches"); + + b.Property("NumberOfMessagesArchived") + .HasColumnType("integer") + .HasColumnName("number_of_messages_archived"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("request_id"); + + b.Property("Started") + .HasColumnType("timestamp with time zone") + .HasColumnName("started"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("integer") + .HasColumnName("total_number_of_messages"); + + b.HasKey("Id") + .HasName("p_k_archive_operations"); + + b.HasIndex("ArchiveState"); + + b.HasIndex("RequestId"); + + b.HasIndex("ArchiveType", "RequestId") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("category"); + + b.Property("CustomCheckId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("custom_check_id"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("endpoint_name"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("ReportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reported_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("p_k_custom_checks"); + + b.HasIndex("Status"); + + b.ToTable("CustomChecks", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("TrackInstances") + .HasColumnType("boolean") + .HasColumnName("track_instances"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("category"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EventType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("event_type"); + + b.Property("RaisedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("raised_at"); + + b.Property("RelatedTo") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("related_to"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("p_k_event_log_items"); + + b.HasIndex("RaisedAt"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dispatch_context_json"); + + b.HasKey("Id") + .HasName("p_k_external_integration_dispatch_requests"); + + b.HasIndex("CreatedAt"); + + b.ToTable("ExternalIntegrationDispatchRequests", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedErrorImportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExceptionInfo") + .HasColumnType("text") + .HasColumnName("exception_info"); + + b.Property("MessageJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_json"); + + b.HasKey("Id") + .HasName("p_k_failed_error_imports"); + + b.ToTable("FailedErrorImports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("conversation_id"); + + b.Property("CriticalTime") + .HasColumnType("interval") + .HasColumnName("critical_time"); + + b.Property("DeliveryTime") + .HasColumnType("interval") + .HasColumnName("delivery_time"); + + b.Property("ExceptionMessage") + .HasColumnType("text") + .HasColumnName("exception_message"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("exception_type"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("failure_groups_json"); + + b.Property("LastProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_processed_at"); + + b.Property("MessageId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("message_id"); + + b.Property("MessageType") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("integer") + .HasColumnName("number_of_processing_attempts"); + + b.Property("PrimaryFailureGroupId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("primary_failure_group_id"); + + b.Property("ProcessingAttemptsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("processing_attempts_json"); + + b.Property("ProcessingTime") + .HasColumnType("interval") + .HasColumnName("processing_time"); + + b.Property("QueueAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("queue_address"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("receiving_endpoint_name"); + + b.Property("SendingEndpointName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sending_endpoint_name"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeSent") + .HasColumnType("timestamp with time zone") + .HasColumnName("time_sent"); + + b.Property("UniqueMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("unique_message_id"); + + b.HasKey("Id") + .HasName("p_k_failed_messages"); + + b.HasIndex("CriticalTime"); + + b.HasIndex("DeliveryTime"); + + b.HasIndex("MessageId"); + + b.HasIndex("ProcessingTime"); + + b.HasIndex("UniqueMessageId") + .IsUnique(); + + b.HasIndex("ConversationId", "LastProcessedAt"); + + b.HasIndex("MessageType", "TimeSent"); + + b.HasIndex("ReceivingEndpointName", "TimeSent"); + + b.HasIndex("Status", "LastProcessedAt"); + + b.HasIndex("Status", "QueueAddress"); + + b.HasIndex("PrimaryFailureGroupId", "Status", "LastProcessedAt"); + + b.HasIndex("QueueAddress", "Status", "LastProcessedAt"); + + b.HasIndex("ReceivingEndpointName", "Status", "LastProcessedAt"); + + b.ToTable("FailedMessages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageRetryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FailedMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("failed_message_id"); + + b.Property("RetryBatchId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("retry_batch_id"); + + b.Property("StageAttempts") + .HasColumnType("integer") + .HasColumnName("stage_attempts"); + + b.HasKey("Id") + .HasName("p_k_failed_message_retries"); + + b.HasIndex("FailedMessageId"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("FailedMessageRetries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.GroupCommentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text") + .HasColumnName("comment"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("group_id"); + + b.HasKey("Id") + .HasName("p_k_group_comments"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupComments", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("endpoint_name"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host_display_name"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("Monitored") + .HasColumnType("boolean") + .HasColumnName("monitored"); + + b.HasKey("Id") + .HasName("p_k_known_endpoints"); + + b.ToTable("KnownEndpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Body") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("BodySize") + .HasColumnType("integer") + .HasColumnName("body_size"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("content_type"); + + b.Property("Etag") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("etag"); + + b.HasKey("Id") + .HasName("p_k_message_bodies"); + + b.ToTable("MessageBodies", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageRedirectsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ETag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("e_tag"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("RedirectsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("redirects_json"); + + b.HasKey("Id") + .HasName("p_k_message_redirects"); + + b.ToTable("MessageRedirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.NotificationsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EmailSettingsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email_settings_json"); + + b.HasKey("Id") + .HasName("p_k_notifications_settings"); + + b.ToTable("NotificationsSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.QueueAddressEntity", b => + { + b.Property("PhysicalAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("physical_address"); + + b.Property("FailedMessageCount") + .HasColumnType("integer") + .HasColumnName("failed_message_count"); + + b.HasKey("PhysicalAddress"); + + b.ToTable("QueueAddresses", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Classifier") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("classifier"); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("FailureRetriesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("failure_retries_json"); + + b.Property("InitialBatchSize") + .HasColumnType("integer") + .HasColumnName("initial_batch_size"); + + b.Property("Last") + .HasColumnType("timestamp with time zone") + .HasColumnName("last"); + + b.Property("Originator") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("originator"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("request_id"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("retry_session_id"); + + b.Property("RetryType") + .HasColumnType("integer") + .HasColumnName("retry_type"); + + b.Property("StagingId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("staging_id"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("p_k_retry_batches"); + + b.HasIndex("RetrySessionId"); + + b.HasIndex("StagingId"); + + b.HasIndex("Status"); + + b.ToTable("RetryBatches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RetryBatchId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("retry_batch_id"); + + b.HasKey("Id") + .HasName("p_k_retry_batch_now_forwarding"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("RetryBatchNowForwarding", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryHistoryEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("id"); + + b.Property("HistoricOperationsJson") + .HasColumnType("text") + .HasColumnName("historic_operations_json"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("text") + .HasColumnName("unacknowledged_operations_json"); + + b.HasKey("Id") + .HasName("p_k_retry_history"); + + b.ToTable("RetryHistory", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("id"); + + b.Property("MessageTypeTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type_type_name"); + + b.Property("MessageTypeVersion") + .HasColumnType("integer") + .HasColumnName("message_type_version"); + + b.Property("SubscribersJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subscribers_json"); + + b.HasKey("Id") + .HasName("p_k_subscriptions"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("TrialEndDate") + .HasColumnType("date") + .HasColumnName("trial_end_date"); + + b.HasKey("Id") + .HasName("p_k_trial_licenses"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs new file mode 100644 index 0000000000..d2ec89fd31 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs @@ -0,0 +1,513 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchiveOperations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + request_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + group_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + archive_type = table.Column(type: "integer", nullable: false), + archive_state = table.Column(type: "integer", nullable: false), + total_number_of_messages = table.Column(type: "integer", nullable: false), + number_of_messages_archived = table.Column(type: "integer", nullable: false), + number_of_batches = table.Column(type: "integer", nullable: false), + current_batch = table.Column(type: "integer", nullable: false), + started = table.Column(type: "timestamp with time zone", nullable: false), + last = table.Column(type: "timestamp with time zone", nullable: true), + completion_time = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_archive_operations", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "CustomChecks", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + custom_check_id = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + category = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + status = table.Column(type: "integer", nullable: false), + reported_at = table.Column(type: "timestamp with time zone", nullable: false), + failure_reason = table.Column(type: "text", nullable: true), + endpoint_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + host_id = table.Column(type: "uuid", nullable: false), + host = table.Column(type: "character varying(500)", maxLength: 500, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_custom_checks", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "EndpointSettings", + columns: table => new + { + name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + track_instances = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EndpointSettings", x => x.name); + }); + + migrationBuilder.CreateTable( + name: "EventLogItems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + description = table.Column(type: "text", nullable: false), + severity = table.Column(type: "integer", nullable: false), + raised_at = table.Column(type: "timestamp with time zone", nullable: false), + related_to = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + category = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + event_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_event_log_items", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "ExternalIntegrationDispatchRequests", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + dispatch_context_json = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_external_integration_dispatch_requests", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "FailedErrorImports", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + message_json = table.Column(type: "text", nullable: false), + exception_info = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_failed_error_imports", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "FailedMessageRetries", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + failed_message_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + retry_batch_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + stage_attempts = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_failed_message_retries", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "FailedMessages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + unique_message_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + status = table.Column(type: "integer", nullable: false), + processing_attempts_json = table.Column(type: "text", nullable: false), + failure_groups_json = table.Column(type: "text", nullable: false), + primary_failure_group_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + message_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + message_type = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + time_sent = table.Column(type: "timestamp with time zone", nullable: true), + sending_endpoint_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + receiving_endpoint_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + exception_type = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + exception_message = table.Column(type: "text", nullable: true), + queue_address = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + number_of_processing_attempts = table.Column(type: "integer", nullable: true), + last_processed_at = table.Column(type: "timestamp with time zone", nullable: true), + conversation_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + critical_time = table.Column(type: "interval", nullable: true), + processing_time = table.Column(type: "interval", nullable: true), + delivery_time = table.Column(type: "interval", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_failed_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "GroupComments", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + group_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + comment = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_group_comments", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "KnownEndpoints", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + endpoint_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + host_id = table.Column(type: "uuid", nullable: false), + host = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + host_display_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + monitored = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_known_endpoints", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "MessageBodies", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + body = table.Column(type: "bytea", nullable: false), + content_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + body_size = table.Column(type: "integer", nullable: false), + etag = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_message_bodies", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "MessageRedirects", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + e_tag = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + last_modified = table.Column(type: "timestamp with time zone", nullable: false), + redirects_json = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_message_redirects", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "NotificationsSettings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + email_settings_json = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_notifications_settings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "QueueAddresses", + columns: table => new + { + physical_address = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + failed_message_count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_QueueAddresses", x => x.physical_address); + }); + + migrationBuilder.CreateTable( + name: "RetryBatches", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + context = table.Column(type: "text", nullable: true), + retry_session_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + staging_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + originator = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + classifier = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + start_time = table.Column(type: "timestamp with time zone", nullable: false), + last = table.Column(type: "timestamp with time zone", nullable: true), + request_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + initial_batch_size = table.Column(type: "integer", nullable: false), + retry_type = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + failure_retries_json = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_retry_batches", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "RetryBatchNowForwarding", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + retry_batch_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_retry_batch_now_forwarding", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "RetryHistory", + columns: table => new + { + id = table.Column(type: "integer", nullable: false, defaultValue: 1), + historic_operations_json = table.Column(type: "text", nullable: true), + unacknowledged_operations_json = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_retry_history", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + id = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + message_type_type_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + message_type_version = table.Column(type: "integer", nullable: false), + subscribers_json = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_subscriptions", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + id = table.Column(type: "integer", nullable: false), + trial_end_date = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_trial_licenses", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_archive_state", + table: "ArchiveOperations", + column: "archive_state"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_archive_type_request_id", + table: "ArchiveOperations", + columns: new[] { "archive_type", "request_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_request_id", + table: "ArchiveOperations", + column: "request_id"); + + migrationBuilder.CreateIndex( + name: "IX_CustomChecks_status", + table: "CustomChecks", + column: "status"); + + migrationBuilder.CreateIndex( + name: "IX_EventLogItems_raised_at", + table: "EventLogItems", + column: "raised_at"); + + migrationBuilder.CreateIndex( + name: "IX_ExternalIntegrationDispatchRequests_created_at", + table: "ExternalIntegrationDispatchRequests", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessageRetries_failed_message_id", + table: "FailedMessageRetries", + column: "failed_message_id"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessageRetries_retry_batch_id", + table: "FailedMessageRetries", + column: "retry_batch_id"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_conversation_id_last_processed_at", + table: "FailedMessages", + columns: new[] { "conversation_id", "last_processed_at" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_critical_time", + table: "FailedMessages", + column: "critical_time"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_delivery_time", + table: "FailedMessages", + column: "delivery_time"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_message_id", + table: "FailedMessages", + column: "message_id"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_message_type_time_sent", + table: "FailedMessages", + columns: new[] { "message_type", "time_sent" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_primary_failure_group_id_status_last_process~", + table: "FailedMessages", + columns: new[] { "primary_failure_group_id", "status", "last_processed_at" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_processing_time", + table: "FailedMessages", + column: "processing_time"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_queue_address_status_last_processed_at", + table: "FailedMessages", + columns: new[] { "queue_address", "status", "last_processed_at" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_receiving_endpoint_name_status_last_processe~", + table: "FailedMessages", + columns: new[] { "receiving_endpoint_name", "status", "last_processed_at" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_receiving_endpoint_name_time_sent", + table: "FailedMessages", + columns: new[] { "receiving_endpoint_name", "time_sent" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_status_last_processed_at", + table: "FailedMessages", + columns: new[] { "status", "last_processed_at" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_status_queue_address", + table: "FailedMessages", + columns: new[] { "status", "queue_address" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_unique_message_id", + table: "FailedMessages", + column: "unique_message_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GroupComments_group_id", + table: "GroupComments", + column: "group_id"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_retry_session_id", + table: "RetryBatches", + column: "retry_session_id"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_staging_id", + table: "RetryBatches", + column: "staging_id"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_status", + table: "RetryBatches", + column: "status"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatchNowForwarding_retry_batch_id", + table: "RetryBatchNowForwarding", + column: "retry_batch_id"); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_message_type_type_name_message_type_version", + table: "Subscriptions", + columns: new[] { "message_type_type_name", "message_type_version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveOperations"); + + migrationBuilder.DropTable( + name: "CustomChecks"); + + migrationBuilder.DropTable( + name: "EndpointSettings"); + + migrationBuilder.DropTable( + name: "EventLogItems"); + + migrationBuilder.DropTable( + name: "ExternalIntegrationDispatchRequests"); + + migrationBuilder.DropTable( + name: "FailedErrorImports"); + + migrationBuilder.DropTable( + name: "FailedMessageRetries"); + + migrationBuilder.DropTable( + name: "FailedMessages"); + + migrationBuilder.DropTable( + name: "GroupComments"); + + migrationBuilder.DropTable( + name: "KnownEndpoints"); + + migrationBuilder.DropTable( + name: "MessageBodies"); + + migrationBuilder.DropTable( + name: "MessageRedirects"); + + migrationBuilder.DropTable( + name: "NotificationsSettings"); + + migrationBuilder.DropTable( + name: "QueueAddresses"); + + migrationBuilder.DropTable( + name: "RetryBatches"); + + migrationBuilder.DropTable( + name: "RetryBatchNowForwarding"); + + migrationBuilder.DropTable( + name: "RetryHistory"); + + migrationBuilder.DropTable( + name: "Subscriptions"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs index f342bfcb71..231b1b5a84 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -1,6 +1,9 @@ -// +// +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using ServiceControl.Persistence.Sql.PostgreSQL; #nullable disable @@ -19,20 +22,719 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArchiveState") + .HasColumnType("integer") + .HasColumnName("archive_state"); + + b.Property("ArchiveType") + .HasColumnType("integer") + .HasColumnName("archive_type"); + + b.Property("CompletionTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("completion_time"); + + b.Property("CurrentBatch") + .HasColumnType("integer") + .HasColumnName("current_batch"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("group_name"); + + b.Property("Last") + .HasColumnType("timestamp with time zone") + .HasColumnName("last"); + + b.Property("NumberOfBatches") + .HasColumnType("integer") + .HasColumnName("number_of_batches"); + + b.Property("NumberOfMessagesArchived") + .HasColumnType("integer") + .HasColumnName("number_of_messages_archived"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("request_id"); + + b.Property("Started") + .HasColumnType("timestamp with time zone") + .HasColumnName("started"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("integer") + .HasColumnName("total_number_of_messages"); + + b.HasKey("Id") + .HasName("p_k_archive_operations"); + + b.HasIndex("ArchiveState"); + + b.HasIndex("RequestId"); + + b.HasIndex("ArchiveType", "RequestId") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("category"); + + b.Property("CustomCheckId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("custom_check_id"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("endpoint_name"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("ReportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reported_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("p_k_custom_checks"); + + b.HasIndex("Status"); + + b.ToTable("CustomChecks", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("TrackInstances") + .HasColumnType("boolean") + .HasColumnName("track_instances"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("category"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EventType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("event_type"); + + b.Property("RaisedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("raised_at"); + + b.Property("RelatedTo") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("related_to"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("p_k_event_log_items"); + + b.HasIndex("RaisedAt"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dispatch_context_json"); + + b.HasKey("Id") + .HasName("p_k_external_integration_dispatch_requests"); + + b.HasIndex("CreatedAt"); + + b.ToTable("ExternalIntegrationDispatchRequests", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedErrorImportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExceptionInfo") + .HasColumnType("text") + .HasColumnName("exception_info"); + + b.Property("MessageJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_json"); + + b.HasKey("Id") + .HasName("p_k_failed_error_imports"); + + b.ToTable("FailedErrorImports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("conversation_id"); + + b.Property("CriticalTime") + .HasColumnType("interval") + .HasColumnName("critical_time"); + + b.Property("DeliveryTime") + .HasColumnType("interval") + .HasColumnName("delivery_time"); + + b.Property("ExceptionMessage") + .HasColumnType("text") + .HasColumnName("exception_message"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("exception_type"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("failure_groups_json"); + + b.Property("LastProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_processed_at"); + + b.Property("MessageId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("message_id"); + + b.Property("MessageType") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("integer") + .HasColumnName("number_of_processing_attempts"); + + b.Property("PrimaryFailureGroupId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("primary_failure_group_id"); + + b.Property("ProcessingAttemptsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("processing_attempts_json"); + + b.Property("ProcessingTime") + .HasColumnType("interval") + .HasColumnName("processing_time"); + + b.Property("QueueAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("queue_address"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("receiving_endpoint_name"); + + b.Property("SendingEndpointName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sending_endpoint_name"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeSent") + .HasColumnType("timestamp with time zone") + .HasColumnName("time_sent"); + + b.Property("UniqueMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("unique_message_id"); + + b.HasKey("Id") + .HasName("p_k_failed_messages"); + + b.HasIndex("CriticalTime"); + + b.HasIndex("DeliveryTime"); + + b.HasIndex("MessageId"); + + b.HasIndex("ProcessingTime"); + + b.HasIndex("UniqueMessageId") + .IsUnique(); + + b.HasIndex("ConversationId", "LastProcessedAt"); + + b.HasIndex("MessageType", "TimeSent"); + + b.HasIndex("ReceivingEndpointName", "TimeSent"); + + b.HasIndex("Status", "LastProcessedAt"); + + b.HasIndex("Status", "QueueAddress"); + + b.HasIndex("PrimaryFailureGroupId", "Status", "LastProcessedAt"); + + b.HasIndex("QueueAddress", "Status", "LastProcessedAt"); + + b.HasIndex("ReceivingEndpointName", "Status", "LastProcessedAt"); + + b.ToTable("FailedMessages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageRetryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FailedMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("failed_message_id"); + + b.Property("RetryBatchId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("retry_batch_id"); + + b.Property("StageAttempts") + .HasColumnType("integer") + .HasColumnName("stage_attempts"); + + b.HasKey("Id") + .HasName("p_k_failed_message_retries"); + + b.HasIndex("FailedMessageId"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("FailedMessageRetries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.GroupCommentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text") + .HasColumnName("comment"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("group_id"); + + b.HasKey("Id") + .HasName("p_k_group_comments"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupComments", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("endpoint_name"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host_display_name"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("Monitored") + .HasColumnType("boolean") + .HasColumnName("monitored"); + + b.HasKey("Id") + .HasName("p_k_known_endpoints"); + + b.ToTable("KnownEndpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Body") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("BodySize") + .HasColumnType("integer") + .HasColumnName("body_size"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("content_type"); + + b.Property("Etag") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("etag"); + + b.HasKey("Id") + .HasName("p_k_message_bodies"); + + b.ToTable("MessageBodies", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageRedirectsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ETag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("e_tag"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("RedirectsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("redirects_json"); + + b.HasKey("Id") + .HasName("p_k_message_redirects"); + + b.ToTable("MessageRedirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.NotificationsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EmailSettingsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email_settings_json"); + + b.HasKey("Id") + .HasName("p_k_notifications_settings"); + + b.ToTable("NotificationsSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.QueueAddressEntity", b => + { + b.Property("PhysicalAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("physical_address"); + + b.Property("FailedMessageCount") + .HasColumnType("integer") + .HasColumnName("failed_message_count"); + + b.HasKey("PhysicalAddress"); + + b.ToTable("QueueAddresses", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Classifier") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("classifier"); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("FailureRetriesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("failure_retries_json"); + + b.Property("InitialBatchSize") + .HasColumnType("integer") + .HasColumnName("initial_batch_size"); + + b.Property("Last") + .HasColumnType("timestamp with time zone") + .HasColumnName("last"); + + b.Property("Originator") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("originator"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("request_id"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("retry_session_id"); + + b.Property("RetryType") + .HasColumnType("integer") + .HasColumnName("retry_type"); + + b.Property("StagingId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("staging_id"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("p_k_retry_batches"); + + b.HasIndex("RetrySessionId"); + + b.HasIndex("StagingId"); + + b.HasIndex("Status"); + + b.ToTable("RetryBatches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RetryBatchId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("retry_batch_id"); + + b.HasKey("Id") + .HasName("p_k_retry_batch_now_forwarding"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("RetryBatchNowForwarding", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryHistoryEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("id"); + + b.Property("HistoricOperationsJson") + .HasColumnType("text") + .HasColumnName("historic_operations_json"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("text") + .HasColumnName("unacknowledged_operations_json"); + + b.HasKey("Id") + .HasName("p_k_retry_history"); + + b.ToTable("RetryHistory", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("id"); + + b.Property("MessageTypeTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type_type_name"); + + b.Property("MessageTypeVersion") + .HasColumnType("integer") + .HasColumnName("message_type_version"); + + b.Property("SubscribersJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subscribers_json"); + + b.HasKey("Id") + .HasName("p_k_subscriptions"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") .HasColumnType("integer") - .HasColumnName("id") - .HasDefaultValue(1); + .HasColumnName("id"); b.Property("TrialEndDate") .HasColumnType("date") - .HasColumnName("trialenddate"); + .HasColumnName("trial_end_date"); - b.HasKey("Id"); + b.HasKey("Id") + .HasName("p_k_trial_licenses"); - b.ToTable("triallicense", (string)null); + b.ToTable("TrialLicense", (string)null); }); #pragma warning restore 612, 618 } From ba7cbd9e96ec7b4fc9c6e9da9f22968d6fbd52a8 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 11/16] Generate initial SQL Server database migration --- .../20241208000000_InitialCreate.cs | 32 - .../20251214204341_InitialCreate.Designer.cs | 622 ++++++++++++++++++ .../20251214204341_InitialCreate.cs | 512 ++++++++++++++ .../SqlServerDbContextModelSnapshot.cs | 585 +++++++++++++++- 4 files changed, 1717 insertions(+), 34 deletions(-) delete mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs deleted file mode 100644 index bbb196af77..0000000000 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20241208000000_InitialCreate.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ServiceControl.Persistence.Sql.SqlServer.Migrations; - -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -/// -public partial class InitialCreate : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "TrialLicense", - columns: table => new - { - Id = table.Column(type: "int", nullable: false, defaultValue: 1), - TrialEndDate = table.Column(type: "date", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_TrialLicense", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "TrialLicense"); - } -} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs new file mode 100644 index 0000000000..d938955ecd --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs @@ -0,0 +1,622 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.Sql.SqlServer; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + [DbContext(typeof(SqlServerDbContext))] + [Migration("20251214204341_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchiveState") + .HasColumnType("int"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("CompletionTime") + .HasColumnType("datetime2"); + + b.Property("CurrentBatch") + .HasColumnType("int"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Last") + .HasColumnType("datetime2"); + + b.Property("NumberOfBatches") + .HasColumnType("int"); + + b.Property("NumberOfMessagesArchived") + .HasColumnType("int"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Started") + .HasColumnType("datetime2"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ArchiveState"); + + b.HasIndex("RequestId"); + + b.HasIndex("ArchiveType", "RequestId") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CustomCheckId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailureReason") + .HasColumnType("nvarchar(max)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HostId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReportedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("CustomChecks", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TrackInstances") + .HasColumnType("bit"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RaisedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedTo") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RaisedAt"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("ExternalIntegrationDispatchRequests", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedErrorImportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExceptionInfo") + .HasColumnType("nvarchar(max)"); + + b.Property("MessageJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("FailedErrorImports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CriticalTime") + .HasColumnType("time"); + + b.Property("DeliveryTime") + .HasColumnType("time"); + + b.Property("ExceptionMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastProcessedAt") + .HasColumnType("datetime2"); + + b.Property("MessageId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MessageType") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("int"); + + b.Property("PrimaryFailureGroupId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ProcessingAttemptsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessingTime") + .HasColumnType("time"); + + b.Property("QueueAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SendingEndpointName") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TimeSent") + .HasColumnType("datetime2"); + + b.Property("UniqueMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CriticalTime"); + + b.HasIndex("DeliveryTime"); + + b.HasIndex("MessageId"); + + b.HasIndex("ProcessingTime"); + + b.HasIndex("UniqueMessageId") + .IsUnique(); + + b.HasIndex("ConversationId", "LastProcessedAt"); + + b.HasIndex("MessageType", "TimeSent"); + + b.HasIndex("ReceivingEndpointName", "TimeSent"); + + b.HasIndex("Status", "LastProcessedAt"); + + b.HasIndex("Status", "QueueAddress"); + + b.HasIndex("PrimaryFailureGroupId", "Status", "LastProcessedAt"); + + b.HasIndex("QueueAddress", "Status", "LastProcessedAt"); + + b.HasIndex("ReceivingEndpointName", "Status", "LastProcessedAt"); + + b.ToTable("FailedMessages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageRetryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("FailedMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryBatchId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StageAttempts") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FailedMessageId"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("FailedMessageRetries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.GroupCommentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupComments", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HostId") + .HasColumnType("uniqueidentifier"); + + b.Property("Monitored") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("KnownEndpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("BodySize") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Etag") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("MessageBodies", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageRedirectsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ETag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("RedirectsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("MessageRedirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.NotificationsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EmailSettingsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("NotificationsSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.QueueAddressEntity", b => + { + b.Property("PhysicalAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailedMessageCount") + .HasColumnType("int"); + + b.HasKey("PhysicalAddress"); + + b.ToTable("QueueAddresses", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Classifier") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Context") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureRetriesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InitialBatchSize") + .HasColumnType("int"); + + b.Property("Last") + .HasColumnType("datetime2"); + + b.Property("Originator") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("StagingId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RetrySessionId"); + + b.HasIndex("StagingId"); + + b.HasIndex("Status"); + + b.ToTable("RetryBatches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("RetryBatchId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("RetryBatchNowForwarding", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryHistoryEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("HistoricOperationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("RetryHistory", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MessageTypeTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MessageTypeVersion") + .HasColumnType("int"); + + b.Property("SubscribersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs new file mode 100644 index 0000000000..5ad61428b3 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs @@ -0,0 +1,512 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchiveOperations", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + RequestId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + GroupName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ArchiveType = table.Column(type: "int", nullable: false), + ArchiveState = table.Column(type: "int", nullable: false), + TotalNumberOfMessages = table.Column(type: "int", nullable: false), + NumberOfMessagesArchived = table.Column(type: "int", nullable: false), + NumberOfBatches = table.Column(type: "int", nullable: false), + CurrentBatch = table.Column(type: "int", nullable: false), + Started = table.Column(type: "datetime2", nullable: false), + Last = table.Column(type: "datetime2", nullable: true), + CompletionTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ArchiveOperations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CustomChecks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CustomCheckId = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Category = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "int", nullable: false), + ReportedAt = table.Column(type: "datetime2", nullable: false), + FailureReason = table.Column(type: "nvarchar(max)", nullable: true), + EndpointName = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + HostId = table.Column(type: "uniqueidentifier", nullable: false), + Host = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomChecks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EndpointSettings", + columns: table => new + { + Name = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + TrackInstances = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EndpointSettings", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "EventLogItems", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: false), + Severity = table.Column(type: "int", nullable: false), + RaisedAt = table.Column(type: "datetime2", nullable: false), + RelatedTo = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + Category = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + EventType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EventLogItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ExternalIntegrationDispatchRequests", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DispatchContextJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalIntegrationDispatchRequests", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FailedErrorImports", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + MessageJson = table.Column(type: "nvarchar(max)", nullable: false), + ExceptionInfo = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FailedErrorImports", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FailedMessageRetries", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FailedMessageId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + RetryBatchId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + StageAttempts = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FailedMessageRetries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FailedMessages", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UniqueMessageId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "int", nullable: false), + ProcessingAttemptsJson = table.Column(type: "nvarchar(max)", nullable: false), + FailureGroupsJson = table.Column(type: "nvarchar(max)", nullable: false), + PrimaryFailureGroupId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + MessageId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + MessageType = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + TimeSent = table.Column(type: "datetime2", nullable: true), + SendingEndpointName = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ReceivingEndpointName = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ExceptionType = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ExceptionMessage = table.Column(type: "nvarchar(max)", nullable: true), + QueueAddress = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + NumberOfProcessingAttempts = table.Column(type: "int", nullable: true), + LastProcessedAt = table.Column(type: "datetime2", nullable: true), + ConversationId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + CriticalTime = table.Column(type: "time", nullable: true), + ProcessingTime = table.Column(type: "time", nullable: true), + DeliveryTime = table.Column(type: "time", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FailedMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GroupComments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + GroupId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Comment = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupComments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "KnownEndpoints", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EndpointName = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + HostId = table.Column(type: "uniqueidentifier", nullable: false), + Host = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + HostDisplayName = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Monitored = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KnownEndpoints", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MessageBodies", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Body = table.Column(type: "varbinary(max)", nullable: false), + ContentType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + BodySize = table.Column(type: "int", nullable: false), + Etag = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MessageBodies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MessageRedirects", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ETag = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + LastModified = table.Column(type: "datetime2", nullable: false), + RedirectsJson = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MessageRedirects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NotificationsSettings", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EmailSettingsJson = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationsSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "QueueAddresses", + columns: table => new + { + PhysicalAddress = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + FailedMessageCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_QueueAddresses", x => x.PhysicalAddress); + }); + + migrationBuilder.CreateTable( + name: "RetryBatches", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Context = table.Column(type: "nvarchar(max)", nullable: true), + RetrySessionId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + StagingId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Originator = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + Classifier = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + StartTime = table.Column(type: "datetime2", nullable: false), + Last = table.Column(type: "datetime2", nullable: true), + RequestId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + InitialBatchSize = table.Column(type: "int", nullable: false), + RetryType = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + FailureRetriesJson = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RetryBatches", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RetryBatchNowForwarding", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RetryBatchId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RetryBatchNowForwarding", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RetryHistory", + columns: table => new + { + Id = table.Column(type: "int", nullable: false, defaultValue: 1), + HistoricOperationsJson = table.Column(type: "nvarchar(max)", nullable: true), + UnacknowledgedOperationsJson = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RetryHistory", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + Id = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + MessageTypeTypeName = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + MessageTypeVersion = table.Column(type: "int", nullable: false), + SubscribersJson = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + Id = table.Column(type: "int", nullable: false), + TrialEndDate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrialLicense", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_ArchiveState", + table: "ArchiveOperations", + column: "ArchiveState"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_ArchiveType_RequestId", + table: "ArchiveOperations", + columns: new[] { "ArchiveType", "RequestId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_RequestId", + table: "ArchiveOperations", + column: "RequestId"); + + migrationBuilder.CreateIndex( + name: "IX_CustomChecks_Status", + table: "CustomChecks", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_EventLogItems_RaisedAt", + table: "EventLogItems", + column: "RaisedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ExternalIntegrationDispatchRequests_CreatedAt", + table: "ExternalIntegrationDispatchRequests", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessageRetries_FailedMessageId", + table: "FailedMessageRetries", + column: "FailedMessageId"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessageRetries_RetryBatchId", + table: "FailedMessageRetries", + column: "RetryBatchId"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ConversationId_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "ConversationId", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_CriticalTime", + table: "FailedMessages", + column: "CriticalTime"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_DeliveryTime", + table: "FailedMessages", + column: "DeliveryTime"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_MessageId", + table: "FailedMessages", + column: "MessageId"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_MessageType_TimeSent", + table: "FailedMessages", + columns: new[] { "MessageType", "TimeSent" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_PrimaryFailureGroupId_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "PrimaryFailureGroupId", "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ProcessingTime", + table: "FailedMessages", + column: "ProcessingTime"); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_QueueAddress_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "QueueAddress", "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ReceivingEndpointName_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "ReceivingEndpointName", "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_ReceivingEndpointName_TimeSent", + table: "FailedMessages", + columns: new[] { "ReceivingEndpointName", "TimeSent" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_Status_LastProcessedAt", + table: "FailedMessages", + columns: new[] { "Status", "LastProcessedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_Status_QueueAddress", + table: "FailedMessages", + columns: new[] { "Status", "QueueAddress" }); + + migrationBuilder.CreateIndex( + name: "IX_FailedMessages_UniqueMessageId", + table: "FailedMessages", + column: "UniqueMessageId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GroupComments_GroupId", + table: "GroupComments", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_RetrySessionId", + table: "RetryBatches", + column: "RetrySessionId"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_StagingId", + table: "RetryBatches", + column: "StagingId"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatches_Status", + table: "RetryBatches", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_RetryBatchNowForwarding_RetryBatchId", + table: "RetryBatchNowForwarding", + column: "RetryBatchId"); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_MessageTypeTypeName_MessageTypeVersion", + table: "Subscriptions", + columns: new[] { "MessageTypeTypeName", "MessageTypeVersion" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveOperations"); + + migrationBuilder.DropTable( + name: "CustomChecks"); + + migrationBuilder.DropTable( + name: "EndpointSettings"); + + migrationBuilder.DropTable( + name: "EventLogItems"); + + migrationBuilder.DropTable( + name: "ExternalIntegrationDispatchRequests"); + + migrationBuilder.DropTable( + name: "FailedErrorImports"); + + migrationBuilder.DropTable( + name: "FailedMessageRetries"); + + migrationBuilder.DropTable( + name: "FailedMessages"); + + migrationBuilder.DropTable( + name: "GroupComments"); + + migrationBuilder.DropTable( + name: "KnownEndpoints"); + + migrationBuilder.DropTable( + name: "MessageBodies"); + + migrationBuilder.DropTable( + name: "MessageRedirects"); + + migrationBuilder.DropTable( + name: "NotificationsSettings"); + + migrationBuilder.DropTable( + name: "QueueAddresses"); + + migrationBuilder.DropTable( + name: "RetryBatches"); + + migrationBuilder.DropTable( + name: "RetryBatchNowForwarding"); + + migrationBuilder.DropTable( + name: "RetryHistory"); + + migrationBuilder.DropTable( + name: "Subscriptions"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs index b994999482..2754f2d6ac 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -1,6 +1,9 @@ -// +// +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using ServiceControl.Persistence.Sql.SqlServer; #nullable disable @@ -19,12 +22,590 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchiveState") + .HasColumnType("int"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("CompletionTime") + .HasColumnType("datetime2"); + + b.Property("CurrentBatch") + .HasColumnType("int"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Last") + .HasColumnType("datetime2"); + + b.Property("NumberOfBatches") + .HasColumnType("int"); + + b.Property("NumberOfMessagesArchived") + .HasColumnType("int"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Started") + .HasColumnType("datetime2"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ArchiveState"); + + b.HasIndex("RequestId"); + + b.HasIndex("ArchiveType", "RequestId") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CustomCheckId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailureReason") + .HasColumnType("nvarchar(max)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HostId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReportedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("CustomChecks", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TrackInstances") + .HasColumnType("bit"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RaisedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedTo") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RaisedAt"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("ExternalIntegrationDispatchRequests", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedErrorImportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExceptionInfo") + .HasColumnType("nvarchar(max)"); + + b.Property("MessageJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("FailedErrorImports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CriticalTime") + .HasColumnType("time"); + + b.Property("DeliveryTime") + .HasColumnType("time"); + + b.Property("ExceptionMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastProcessedAt") + .HasColumnType("datetime2"); + + b.Property("MessageId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MessageType") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("int"); + + b.Property("PrimaryFailureGroupId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ProcessingAttemptsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessingTime") + .HasColumnType("time"); + + b.Property("QueueAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SendingEndpointName") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TimeSent") + .HasColumnType("datetime2"); + + b.Property("UniqueMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CriticalTime"); + + b.HasIndex("DeliveryTime"); + + b.HasIndex("MessageId"); + + b.HasIndex("ProcessingTime"); + + b.HasIndex("UniqueMessageId") + .IsUnique(); + + b.HasIndex("ConversationId", "LastProcessedAt"); + + b.HasIndex("MessageType", "TimeSent"); + + b.HasIndex("ReceivingEndpointName", "TimeSent"); + + b.HasIndex("Status", "LastProcessedAt"); + + b.HasIndex("Status", "QueueAddress"); + + b.HasIndex("PrimaryFailureGroupId", "Status", "LastProcessedAt"); + + b.HasIndex("QueueAddress", "Status", "LastProcessedAt"); + + b.HasIndex("ReceivingEndpointName", "Status", "LastProcessedAt"); + + b.ToTable("FailedMessages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.FailedMessageRetryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("FailedMessageId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryBatchId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StageAttempts") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FailedMessageId"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("FailedMessageRetries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.GroupCommentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupComments", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HostId") + .HasColumnType("uniqueidentifier"); + + b.Property("Monitored") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("KnownEndpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("BodySize") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Etag") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("MessageBodies", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageRedirectsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ETag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("RedirectsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("MessageRedirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.NotificationsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EmailSettingsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("NotificationsSettings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.QueueAddressEntity", b => + { + b.Property("PhysicalAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailedMessageCount") + .HasColumnType("int"); + + b.HasKey("PhysicalAddress"); + + b.ToTable("QueueAddresses", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Classifier") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Context") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureRetriesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InitialBatchSize") + .HasColumnType("int"); + + b.Property("Last") + .HasColumnType("datetime2"); + + b.Property("Originator") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("StagingId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RetrySessionId"); + + b.HasIndex("StagingId"); + + b.HasIndex("Status"); + + b.ToTable("RetryBatches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("RetryBatchId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("RetryBatchId"); + + b.ToTable("RetryBatchNowForwarding", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.RetryHistoryEntity", b => { b.Property("Id") .HasColumnType("int") .HasDefaultValue(1); + b.Property("HistoricOperationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("RetryHistory", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MessageTypeTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MessageTypeVersion") + .HasColumnType("int"); + + b.Property("SubscribersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + b.Property("TrialEndDate") .HasColumnType("date"); From 44859be672b8c142e23f4696c82324565ef097d0 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:10:56 +1000 Subject: [PATCH 12/16] Remove unused using statements --- src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs | 1 - .../NoOpServiceControlSubscriptionStorage.cs | 1 - .../PersistenceTestsContext.cs | 2 -- 3 files changed, 4 deletions(-) diff --git a/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs b/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs index 64ed378310..cd556daae8 100644 --- a/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs +++ b/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Persistence.Sql; using System.Collections.Generic; using System.Threading.Tasks; using ServiceControl.Contracts.CustomChecks; -using ServiceControl.CustomChecks; using ServiceControl.Persistence; using ServiceControl.Persistence.Infrastructure; diff --git a/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs b/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs index d398810881..18e0a27963 100644 --- a/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs +++ b/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs @@ -3,7 +3,6 @@ namespace ServiceControl.Persistence.Sql; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using NServiceBus; using NServiceBus.Extensibility; using NServiceBus.Unicast.Subscriptions; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs index 9d8ae3ddb3..45f21ca2e9 100644 --- a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs @@ -1,8 +1,6 @@ namespace ServiceControl.Persistence.Tests; -using System; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ServiceControl.Persistence; From aa63479e8bfeb5c200ff7ecc8fa3de2c0747a674 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 10:42:03 +1000 Subject: [PATCH 13/16] Optimizes EF Core upsert operations Refactors the upsert logic in several data stores to leverage EF Core's change tracking more efficiently. Instead of creating a new entity and then calling Update, the code now fetches the existing entity (if any) and modifies its properties directly. This reduces the overhead and potential issues associated with detached entities. The RecoverabilityIngestionUnitOfWork is also updated to use change tracking for FailedMessageEntity updates. This commit was made on the `john/more_interfaces` branch. --- .../Implementation/EndpointSettingsStore.cs | 15 ++-- .../ErrorMessageDataStore.FailureGroups.cs | 17 ++-- .../MessageRedirectsDataStore.cs | 21 ++--- .../TrialLicenseDataProvider.cs | 13 ++-- .../RecoverabilityIngestionUnitOfWork.cs | 78 +++++++++++-------- 5 files changed, 78 insertions(+), 66 deletions(-) diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs index 106da2441b..4f9ef498be 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs @@ -37,21 +37,20 @@ public Task UpdateEndpointSettings(EndpointSettings settings, CancellationToken { return ExecuteWithDbContext(async dbContext => { - var entity = new EndpointSettingsEntity - { - Name = settings.Name, - TrackInstances = settings.TrackInstances - }; - // Use EF's change tracking for upsert - var existing = await dbContext.EndpointSettings.FindAsync([entity.Name], cancellationToken); + var existing = await dbContext.EndpointSettings.FindAsync([settings.Name], cancellationToken); if (existing == null) { + var entity = new EndpointSettingsEntity + { + Name = settings.Name, + TrackInstances = settings.TrackInstances + }; dbContext.EndpointSettings.Add(entity); } else { - dbContext.EndpointSettings.Update(entity); + existing.TrackInstances = settings.TrackInstances; } await dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs index 4c8ed9652a..73854f5f2e 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs @@ -199,22 +199,23 @@ public Task EditComment(string groupId, string comment) { return ExecuteWithDbContext(async dbContext => { - var commentEntity = new GroupCommentEntity - { - Id = Guid.Parse(groupId), - GroupId = groupId, - Comment = comment - }; + var id = Guid.Parse(groupId); // Use EF's change tracking for upsert - var existing = await dbContext.GroupComments.FindAsync(commentEntity.Id); + var existing = await dbContext.GroupComments.FindAsync(id); if (existing == null) { + var commentEntity = new GroupCommentEntity + { + Id = id, + GroupId = groupId, + Comment = comment + }; dbContext.GroupComments.Add(commentEntity); } else { - dbContext.GroupComments.Update(commentEntity); + existing.Comment = comment; } await dbContext.SaveChangesAsync(); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs index e44b4c8536..28090d2715 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs @@ -50,23 +50,26 @@ public Task Save(MessageRedirectsCollection redirects) var newETag = Guid.NewGuid().ToString(); var newLastModified = DateTime.UtcNow; - var entity = new MessageRedirectsEntity - { - Id = Guid.Parse(MessageRedirectsCollection.DefaultId), - ETag = newETag, - LastModified = newLastModified, - RedirectsJson = redirectsJson - }; + var id = Guid.Parse(MessageRedirectsCollection.DefaultId); // Use EF's change tracking for upsert - var existing = await dbContext.MessageRedirects.FindAsync(entity.Id); + var existing = await dbContext.MessageRedirects.FindAsync(id); if (existing == null) { + var entity = new MessageRedirectsEntity + { + Id = id, + ETag = newETag, + LastModified = newLastModified, + RedirectsJson = redirectsJson + }; dbContext.MessageRedirects.Add(entity); } else { - dbContext.MessageRedirects.Update(entity); + existing.ETag = newETag; + existing.LastModified = newLastModified; + existing.RedirectsJson = redirectsJson; } await dbContext.SaveChangesAsync(); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs index e34664f4a6..2157a991fc 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs @@ -28,21 +28,20 @@ public Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellat { return ExecuteWithDbContext(async dbContext => { - var entity = new TrialLicenseEntity - { - Id = SingletonId, - TrialEndDate = trialEndDate - }; - // Use EF's change tracking for upsert var existing = await dbContext.TrialLicenses.FindAsync([SingletonId], cancellationToken); if (existing == null) { + var entity = new TrialLicenseEntity + { + Id = SingletonId, + TrialEndDate = trialEndDate + }; dbContext.TrialLicenses.Add(entity); } else { - dbContext.TrialLicenses.Update(entity); + existing.TrialEndDate = trialEndDate; } await dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs index 10ae0bd635..157046d65c 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -57,7 +57,6 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe // Load existing message to merge attempts list var existingMessage = await parent.DbContext.FailedMessages - .AsNoTracking() .FirstOrDefaultAsync(fm => fm.UniqueMessageId == uniqueMessageId); List attempts; @@ -77,45 +76,56 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe attempts = [.. attempts .OrderBy(a => a.AttemptedAt) .TakeLast(MaxProcessingAttempts)]; + + // Update the tracked entity + existingMessage.Status = FailedMessageStatus.Unresolved; + existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts); + existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups); + existingMessage.PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null; + existingMessage.MessageId = processingAttempt.MessageId; + existingMessage.MessageType = messageType; + existingMessage.TimeSent = timeSent; + existingMessage.SendingEndpointName = sendingEndpoint?.Name; + existingMessage.ReceivingEndpointName = receivingEndpoint?.Name; + existingMessage.ExceptionType = processingAttempt.FailureDetails?.Exception?.ExceptionType; + existingMessage.ExceptionMessage = processingAttempt.FailureDetails?.Exception?.Message; + existingMessage.QueueAddress = queueAddress; + existingMessage.NumberOfProcessingAttempts = attempts.Count; + existingMessage.LastProcessedAt = processingAttempt.AttemptedAt; + existingMessage.ConversationId = conversationId; + existingMessage.CriticalTime = criticalTime; + existingMessage.ProcessingTime = processingTime; + existingMessage.DeliveryTime = deliveryTime; } else { // First attempt for this message attempts = [processingAttempt]; - } - // Build the complete entity with all fields - var failedMessageEntity = new FailedMessageEntity - { - Id = SequentialGuidGenerator.NewSequentialGuid(), - UniqueMessageId = uniqueMessageId, - Status = FailedMessageStatus.Unresolved, - ProcessingAttemptsJson = JsonSerializer.Serialize(attempts), - FailureGroupsJson = JsonSerializer.Serialize(groups), - PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, - MessageId = processingAttempt.MessageId, - MessageType = messageType, - TimeSent = timeSent, - SendingEndpointName = sendingEndpoint?.Name, - ReceivingEndpointName = receivingEndpoint?.Name, - ExceptionType = processingAttempt.FailureDetails?.Exception?.ExceptionType, - ExceptionMessage = processingAttempt.FailureDetails?.Exception?.Message, - QueueAddress = queueAddress, - NumberOfProcessingAttempts = attempts.Count, - LastProcessedAt = processingAttempt.AttemptedAt, - ConversationId = conversationId, - CriticalTime = criticalTime, - ProcessingTime = processingTime, - DeliveryTime = deliveryTime - }; - - // Use EF's change tracking for upsert - if (existingMessage != null) - { - parent.DbContext.FailedMessages.Update(failedMessageEntity); - } - else - { + // Build the complete entity with all fields + var failedMessageEntity = new FailedMessageEntity + { + Id = SequentialGuidGenerator.NewSequentialGuid(), + UniqueMessageId = uniqueMessageId, + Status = FailedMessageStatus.Unresolved, + ProcessingAttemptsJson = JsonSerializer.Serialize(attempts), + FailureGroupsJson = JsonSerializer.Serialize(groups), + PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, + MessageId = processingAttempt.MessageId, + MessageType = messageType, + TimeSent = timeSent, + SendingEndpointName = sendingEndpoint?.Name, + ReceivingEndpointName = receivingEndpoint?.Name, + ExceptionType = processingAttempt.FailureDetails?.Exception?.ExceptionType, + ExceptionMessage = processingAttempt.FailureDetails?.Exception?.Message, + QueueAddress = queueAddress, + NumberOfProcessingAttempts = attempts.Count, + LastProcessedAt = processingAttempt.AttemptedAt, + ConversationId = conversationId, + CriticalTime = criticalTime, + ProcessingTime = processingTime, + DeliveryTime = deliveryTime + }; parent.DbContext.FailedMessages.Add(failedMessageEntity); } From 34b82287482829de58a8c26bed4ea932b5f578b2 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 15 Dec 2025 17:19:33 +1000 Subject: [PATCH 14/16] Adds licensing data store Adds data store and entities required for persisting licensing and throughput data. This includes adding new tables for licensing metadata, throughput endpoints, and daily throughput data, as well as configurations and a data store implementation to interact with these tables. --- ...ProjectReferences.Persisters.Primary.props | 4 +- .../Abstractions/BasePersistence.cs | 2 + .../DbContexts/ServiceControlDbContextBase.cs | 6 + .../Entities/DailyThroughputEntity.cs | 10 + .../Entities/LicensingMetadataEntity.cs | 8 + .../Entities/ThroughputEndpointEntity.cs | 13 + .../DailyThroughputConfiguration.cs | 31 ++ .../LicensingMetadataEntityConfiguration.cs | 22 ++ .../ThroughputEndpointConfiguration.cs | 32 ++ .../Implementation/LicensingDataStore.cs | 327 ++++++++++++++++++ ...ServiceControl.Persistence.Sql.Core.csproj | 1 + ... 20251215071318_InitialCreate.Designer.cs} | 101 +++++- ...ate.cs => 20251215071318_InitialCreate.cs} | 89 +++++ .../Migrations/MySqlDbContextModelSnapshot.cs | 99 ++++++ ... 20251215071329_InitialCreate.Designer.cs} | 120 ++++++- ...ate.cs => 20251215071329_InitialCreate.cs} | 76 ++++ .../PostgreSqlDbContextModelSnapshot.cs | 118 +++++++ ... 20251215071340_InitialCreate.Designer.cs} | 101 +++++- ...ate.cs => 20251215071340_InitialCreate.cs} | 76 ++++ .../SqlServerDbContextModelSnapshot.cs | 99 ++++++ src/ServiceControl.sln | 15 - 21 files changed, 1331 insertions(+), 19 deletions(-) create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20251214204322_InitialCreate.Designer.cs => 20251215071318_InitialCreate.Designer.cs} (85%) rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20251214204322_InitialCreate.cs => 20251215071318_InitialCreate.cs} (86%) rename src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/{20251214204335_InitialCreate.Designer.cs => 20251215071329_InitialCreate.Designer.cs} (85%) rename src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/{20251214204335_InitialCreate.cs => 20251215071329_InitialCreate.cs} (86%) rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20251214204341_InitialCreate.Designer.cs => 20251215071340_InitialCreate.Designer.cs} (85%) rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20251214204341_InitialCreate.cs => 20251215071340_InitialCreate.cs} (86%) diff --git a/src/ProjectReferences.Persisters.Primary.props b/src/ProjectReferences.Persisters.Primary.props index 0841fa81c2..c0dfc95209 100644 --- a/src/ProjectReferences.Persisters.Primary.props +++ b/src/ProjectReferences.Persisters.Primary.props @@ -2,7 +2,9 @@ - + + + \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs index 974a151788..3329c92b36 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Abstractions; using ServiceControl.Persistence.UnitOfWork; using Implementation; using Implementation.UnitOfWork; +using Particular.LicensingComponent.Persistence; public abstract class BasePersistence { @@ -37,5 +38,6 @@ protected static void RegisterDataStores(IServiceCollection services, bool maint services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs index 95c5163f54..c37f8a9976 100644 --- a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -29,6 +29,9 @@ protected ServiceControlDbContextBase(DbContextOptions options) : base(options) public DbSet GroupComments { get; set; } public DbSet RetryBatchNowForwarding { get; set; } public DbSet NotificationsSettings { get; set; } + public DbSet LicensingMetadata { get; set; } + public DbSet Endpoints { get; set; } + public DbSet Throughput { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -53,6 +56,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new GroupCommentConfiguration()); modelBuilder.ApplyConfiguration(new RetryBatchNowForwardingConfiguration()); modelBuilder.ApplyConfiguration(new NotificationsSettingsConfiguration()); + modelBuilder.ApplyConfiguration(new LicensingMetadataEntityConfiguration()); + modelBuilder.ApplyConfiguration(new ThroughputEndpointConfiguration()); + modelBuilder.ApplyConfiguration(new DailyThroughputConfiguration()); OnModelCreatingProvider(modelBuilder); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs new file mode 100644 index 0000000000..4da7565214 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class DailyThroughputEntity +{ + public int Id { get; set; } + public required string EndpointName { get; set; } + public required string ThroughputSource { get; set; } + public required DateOnly Date { get; set; } + public required long MessageCount { get; set; } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs new file mode 100644 index 0000000000..6b7a97bb83 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class LicensingMetadataEntity +{ + public int Id { get; set; } + public required string Key { get; set; } + public required string Data { get; set; } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs new file mode 100644 index 0000000000..26b875aaf9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs @@ -0,0 +1,13 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class ThroughputEndpointEntity +{ + public int Id { get; set; } + public required string EndpointName { get; set; } + public required string ThroughputSource { get; set; } + public string? SanitizedEndpointName { get; set; } + public string? EndpointIndicators { get; set; } + public string? UserIndicator { get; set; } + public string? Scope { get; set; } + public DateOnly LastCollectedData { get; set; } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs new file mode 100644 index 0000000000..dd5394f438 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs @@ -0,0 +1,31 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class DailyThroughputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DailyThroughput") + .HasIndex(e => new + { + e.EndpointName, + e.ThroughputSource, + e.Date + }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + builder.HasKey(e => e.Id); + builder.Property(e => e.EndpointName) + .IsRequired() + .HasMaxLength(200); + builder.Property(e => e.ThroughputSource) + .IsRequired() + .HasMaxLength(50); + builder.Property(e => e.Date) + .IsRequired(); + builder.Property(e => e.MessageCount) + .IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs new file mode 100644 index 0000000000..06e2318cbd --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class LicensingMetadataEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("LicensingMetadata") + .HasIndex(e => e.Key) + .IsUnique(); + builder.HasKey(e => e.Id); + builder.Property(e => e.Key) + .IsRequired() + .HasMaxLength(200); + builder.Property(e => e.Data) + .IsRequired() + .HasMaxLength(2000); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs new file mode 100644 index 0000000000..dbd1e630ee --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class ThroughputEndpointConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ThroughputEndpoint") + .HasIndex(e => new + { + e.EndpointName, + e.ThroughputSource + }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + builder.HasKey(e => e.Id); + builder.Property(e => e.EndpointName) + .IsRequired() + .HasMaxLength(200); + builder.Property(e => e.ThroughputSource) + .IsRequired() + .HasMaxLength(50); + + builder.Property(e => e.SanitizedEndpointName); + builder.Property(e => e.EndpointIndicators); + builder.Property(e => e.UserIndicator); + builder.Property(e => e.Scope); + builder.Property(e => e.LastCollectedData); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs new file mode 100644 index 0000000000..86c48f644e --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs @@ -0,0 +1,327 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Particular.LicensingComponent.Contracts; +using Particular.LicensingComponent.Persistence; +using ServiceControl.Persistence.Sql.Core.Entities; + +public class LicensingDataStore : DataStoreBase, ILicensingDataStore +{ + public LicensingDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + #region Throughput + static DateOnly DefaultCutOff() + => DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-400)); + + public Task>> GetEndpointThroughputByQueueName(IList queueNames, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var cutOff = DefaultCutOff(); + + var data = await dbContext.Throughput + .AsNoTracking() + .Where(x => queueNames.Contains(x.EndpointName) && x.Date >= cutOff) + .ToListAsync(cancellationToken); + + var lookup = data.ToLookup(x => x.EndpointName); + + Dictionary> result = []; + + foreach (var queueName in queueNames) + { + result[queueName] = [.. lookup[queueName].GroupBy(x => x.ThroughputSource) + .Select(x => new ThroughputData([.. from t in x select new EndpointDailyThroughput(t.Date, t.MessageCount)]) + { + ThroughputSource = Enum.Parse(x.Key) + })]; + } + + return (IDictionary>)result; + }); + } + + public Task IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var cutoffDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days + 1)); + return await dbContext.Throughput.AnyAsync(t => t.Date >= cutoffDate, cancellationToken); + }); + } + + public Task IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var cutoffDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days + 1)); + var endDate = DateOnly.FromDateTime(includeToday ? DateTime.UtcNow : DateTime.UtcNow.AddDays(-1)); + var source = Enum.GetName(throughputSource)!; + return await dbContext.Throughput.AnyAsync(t => t.Date >= cutoffDate && t.Date <= endDate && t.ThroughputSource == source, cancellationToken); + }); + } + + public Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSource, IList throughput, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var source = Enum.GetName(throughputSource)!; + var cutOff = DefaultCutOff(); + var existing = await dbContext.Throughput.Where(t => t.EndpointName == endpointName && t.ThroughputSource == source && t.Date >= cutOff) + .ToListAsync(cancellationToken); + + var lookup = existing.ToLookup(t => t.Date); + + foreach (var t in throughput) + { + var existingEntry = lookup[t.DateUTC].FirstOrDefault(); + if (existingEntry is not null) + { + existingEntry.MessageCount = t.MessageCount; + } + else + { + var newEntry = new DailyThroughputEntity + { + EndpointName = endpointName, + ThroughputSource = source, + Date = t.DateUTC, + MessageCount = t.MessageCount, + }; + await dbContext.Throughput.AddAsync(newEntry, cancellationToken); + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + #endregion + + #region Endpoints + public Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var fromDatabase = await dbContext.Endpoints.AsNoTracking() + .Where(e => endpointIds.Any(id => id.Name == e.EndpointName && Enum.GetName(id.ThroughputSource) == e.ThroughputSource)) + .ToListAsync(cancellationToken); + + var lookup = fromDatabase.Select(MapEndpointEntityToContract).ToLookup(e => e.Id); + + return endpointIds.Select(id => (id, lookup[id].FirstOrDefault())); + }); + } + + public Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) + { + return ExecuteWithDbContext(async dbContext => + { + var fromDatabase = await dbContext.Endpoints.AsNoTracking().SingleOrDefaultAsync(e => e.EndpointName == id.Name && e.ThroughputSource == Enum.GetName(id.ThroughputSource), cancellationToken); + if (fromDatabase is null) + { + return null; + } + + return MapEndpointEntityToContract(fromDatabase); + }); + } + + public Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var endpoints = dbContext.Endpoints.AsNoTracking(); + if (!includePlatformEndpoints) + { + endpoints = endpoints.Where(x => x.EndpointIndicators == null || !x.EndpointIndicators.Contains(Enum.GetName(EndpointIndicator.PlatformEndpoint)!)); + } + + var fromDatabase = await endpoints.ToListAsync(cancellationToken); + + return fromDatabase.Select(MapEndpointEntityToContract); + }); + } + + public Task SaveEndpoint(Endpoint endpoint, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var existing = await dbContext.Endpoints.SingleOrDefaultAsync(e => e.EndpointName == endpoint.Id.Name && e.ThroughputSource == Enum.GetName(endpoint.Id.ThroughputSource), cancellationToken); + if (existing is null) + { + var entity = MapEndpointContractToEntity(endpoint); + await dbContext.Endpoints.AddAsync(entity, cancellationToken); + } + else + { + existing.SanitizedEndpointName = endpoint.SanitizedName; + existing.EndpointIndicators = endpoint.EndpointIndicators is null ? null : string.Join("|", endpoint.EndpointIndicators); + existing.UserIndicator = endpoint.UserIndicator; + existing.Scope = endpoint.Scope; + existing.LastCollectedData = endpoint.LastCollectedDate; + dbContext.Endpoints.Update(existing); + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + + public Task UpdateUserIndicatorOnEndpoints(List userIndicatorUpdates, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var updates = userIndicatorUpdates.ToDictionary(u => u.Name, u => u.UserIndicator); + + // Get all relevant sanitized names from endpoints matched by name + var sanitizedNames = await dbContext.Endpoints + .Where(e => updates.Keys.Contains(e.EndpointName) && e.SanitizedEndpointName != null) + .Select(e => e.SanitizedEndpointName) + .Distinct() + .ToListAsync(cancellationToken); + + // Get all endpoints that match either by name or sanitized name in a single query + var endpoints = await dbContext.Endpoints + .Where(e => updates.Keys.Contains(e.EndpointName) + || (e.SanitizedEndpointName != null && updates.Keys.Contains(e.SanitizedEndpointName)) + || (e.SanitizedEndpointName != null && sanitizedNames.Contains(e.SanitizedEndpointName))) + .ToListAsync(cancellationToken) ?? []; + + foreach (var endpoint in endpoints) + { + if (endpoint.SanitizedEndpointName is not null && updates.TryGetValue(endpoint.SanitizedEndpointName, out var newValueFromSanitizedName)) + { + // Direct match by sanitized name + endpoint.UserIndicator = newValueFromSanitizedName; + } + else if (updates.TryGetValue(endpoint.EndpointName, out var newValueFromEndpoint)) + { + // Direct match by endpoint name - this should also update all endpoints with the same sanitized name + endpoint.UserIndicator = newValueFromEndpoint; + } + else if (endpoint.SanitizedEndpointName != null && sanitizedNames.Contains(endpoint.SanitizedEndpointName)) + { + // This endpoint shares a sanitized name with an endpoint that was matched by name + // Find the update value from the endpoint that has this sanitized name + var matchingEndpoint = endpoints.FirstOrDefault(e => + e.SanitizedEndpointName == endpoint.SanitizedEndpointName && + updates.ContainsKey(e.EndpointName)); + + if (matchingEndpoint != null && updates.TryGetValue(matchingEndpoint.EndpointName, out var cascadedValue)) + { + endpoint.UserIndicator = cascadedValue; + } + } + dbContext.Endpoints.Update(endpoint); + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + + + static Endpoint MapEndpointEntityToContract(ThroughputEndpointEntity entity) + => new(entity.EndpointName, Enum.Parse(entity.ThroughputSource)) + { +#pragma warning disable CS8601 // Possible null reference assignment. + SanitizedName = entity.SanitizedEndpointName, + EndpointIndicators = entity.EndpointIndicators?.Split("|"), + UserIndicator = entity.UserIndicator, + Scope = entity.Scope, + LastCollectedDate = entity.LastCollectedData +#pragma warning restore CS8601 // Possible null reference assignment. + }; + + static ThroughputEndpointEntity MapEndpointContractToEntity(Endpoint endpoint) + => new() + { + EndpointName = endpoint.Id.Name, + ThroughputSource = Enum.GetName(endpoint.Id.ThroughputSource)!, + SanitizedEndpointName = endpoint.SanitizedName, + EndpointIndicators = endpoint.EndpointIndicators is null ? null : string.Join("|", endpoint.EndpointIndicators), + UserIndicator = endpoint.UserIndicator, + Scope = endpoint.Scope, + LastCollectedData = endpoint.LastCollectedDate + }; + + + #endregion + + #region AuditServiceMetadata + + static readonly AuditServiceMetadata EmptyAuditServiceMetadata = new([], []); + public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken) + => SaveMetadata("AuditServiceMetadata", auditServiceMetadata, cancellationToken); + public async Task GetAuditServiceMetadata(CancellationToken cancellationToken = default) + => await LoadMetadata("AuditServiceMetadata", cancellationToken) + ?? EmptyAuditServiceMetadata; + + #endregion + + #region ReportMasks + static readonly List EmptyReportMasks = []; + public Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken) + => SaveMetadata("ReportMasks", reportMasks, cancellationToken); + public async Task> GetReportMasks(CancellationToken cancellationToken) + => await LoadMetadata>("ReportMasks", cancellationToken) ?? EmptyReportMasks; + + #endregion + + #region Broker Metadata + static readonly BrokerMetadata EmptyBrokerMetada = new(ScopeType: null, []); + + public Task SaveBrokerMetadata(BrokerMetadata brokerMetadata, CancellationToken cancellationToken) + => SaveMetadata("BrokerMetadata", brokerMetadata, cancellationToken); + + public async Task GetBrokerMetadata(CancellationToken cancellationToken) + => await LoadMetadata("BrokerMetadata", cancellationToken) ?? EmptyBrokerMetada; + #endregion + + #region Metadata + Task LoadMetadata(string key, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var existing = await dbContext.LicensingMetadata + .AsNoTracking() + .SingleOrDefaultAsync(m => m.Key == key, cancellationToken); + if (existing is null) + { + return default; + } + return JsonSerializer.Deserialize(existing.Data); + }); + } + + Task SaveMetadata(string key, T data, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var existing = await dbContext.LicensingMetadata.SingleOrDefaultAsync(m => m.Key == key, cancellationToken); + + var serialized = JsonSerializer.Serialize(data); + + if (existing is null) + { + LicensingMetadataEntity newMetadata = new() + { + Key = key, + Data = serialized + }; + await dbContext.LicensingMetadata.AddAsync(newMetadata, cancellationToken); + } + else + { + existing.Data = serialized; + dbContext.LicensingMetadata.Update(existing); + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + #endregion +} diff --git a/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj index 2b1582e206..64217d6044 100644 --- a/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj +++ b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.Designer.cs similarity index 85% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.Designer.cs index 550b695c8b..9eb4438c42 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.MySQL.Migrations { [DbContext(typeof(MySqlDbContext))] - [Migration("20251214204322_InitialCreate")] + [Migration("20251215071318_InitialCreate")] partial class InitialCreate { /// @@ -124,6 +124,38 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("CustomChecks", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -401,6 +433,32 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => { b.Property("Id") @@ -604,6 +662,47 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("longtext"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("longtext"); + + b.Property("Scope") + .HasColumnType("longtext"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.cs similarity index 86% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.cs index c6d430c761..fe01444877 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251214204322_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.cs @@ -65,6 +65,25 @@ protected override void Up(MigrationBuilder migrationBuilder) }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "DailyThroughput", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + EndpointName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ThroughputSource = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Date = table.Column(type: "date", nullable: false), + MessageCount = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailyThroughput", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( name: "EndpointSettings", columns: table => new @@ -229,6 +248,23 @@ protected override void Up(MigrationBuilder migrationBuilder) }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "LicensingMetadata", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Key = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Data = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_LicensingMetadata", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( name: "MessageBodies", columns: table => new @@ -372,6 +408,32 @@ protected override void Up(MigrationBuilder migrationBuilder) }) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( + name: "ThroughputEndpoint", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + EndpointName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ThroughputSource = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SanitizedEndpointName = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + EndpointIndicators = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + UserIndicator = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Scope = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + LastCollectedData = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ThroughputEndpoint", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.CreateTable( name: "TrialLicense", columns: table => new @@ -406,6 +468,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "CustomChecks", column: "Status"); + migrationBuilder.CreateIndex( + name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", + table: "DailyThroughput", + columns: new[] { "EndpointName", "ThroughputSource", "Date" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_EventLogItems_RaisedAt", table: "EventLogItems", @@ -497,6 +565,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "GroupComments", column: "GroupId"); + migrationBuilder.CreateIndex( + name: "IX_LicensingMetadata_Key", + table: "LicensingMetadata", + column: "Key", + unique: true); + migrationBuilder.CreateIndex( name: "IX_RetryBatches_RetrySessionId", table: "RetryBatches", @@ -522,6 +596,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Subscriptions", columns: new[] { "MessageTypeTypeName", "MessageTypeVersion" }, unique: true); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "EndpointName", "ThroughputSource" }, + unique: true); } /// @@ -533,6 +613,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "CustomChecks"); + migrationBuilder.DropTable( + name: "DailyThroughput"); + migrationBuilder.DropTable( name: "EndpointSettings"); @@ -557,6 +640,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "KnownEndpoints"); + migrationBuilder.DropTable( + name: "LicensingMetadata"); + migrationBuilder.DropTable( name: "MessageBodies"); @@ -581,6 +667,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "Subscriptions"); + migrationBuilder.DropTable( + name: "ThroughputEndpoint"); + migrationBuilder.DropTable( name: "TrialLicense"); } diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs index 9981136d39..cfff96c939 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -121,6 +121,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CustomChecks", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -398,6 +430,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => { b.Property("Id") @@ -601,6 +659,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("longtext"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("longtext"); + + b.Property("Scope") + .HasColumnType("longtext"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.Designer.cs similarity index 85% rename from src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.Designer.cs index 1b835c69ee..e3229ac52b 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations { [DbContext(typeof(PostgreSqlDbContext))] - [Migration("20251214204335_InitialCreate")] + [Migration("20251215071329_InitialCreate")] partial class InitialCreate { /// @@ -147,6 +147,44 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("CustomChecks", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("MessageCount") + .HasColumnType("bigint") + .HasColumnName("message_count"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.HasKey("Id") + .HasName("p_k_throughput"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -479,6 +517,36 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("data"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.HasKey("Id") + .HasName("p_k_licensing_metadata"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => { b.Property("Id") @@ -724,6 +792,56 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("text") + .HasColumnName("endpoint_indicators"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("LastCollectedData") + .HasColumnType("date") + .HasColumnName("last_collected_data"); + + b.Property("SanitizedEndpointName") + .HasColumnType("text") + .HasColumnName("sanitized_endpoint_name"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.Property("UserIndicator") + .HasColumnType("text") + .HasColumnName("user_indicator"); + + b.HasKey("Id") + .HasName("p_k_endpoints"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.cs similarity index 86% rename from src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.cs index d2ec89fd31..9b44ca6981 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251214204335_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.cs @@ -53,6 +53,22 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("p_k_custom_checks", x => x.id); }); + migrationBuilder.CreateTable( + name: "DailyThroughput", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + endpoint_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + throughput_source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + date = table.Column(type: "date", nullable: false), + message_count = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_throughput", x => x.id); + }); + migrationBuilder.CreateTable( name: "EndpointSettings", columns: table => new @@ -182,6 +198,20 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("p_k_known_endpoints", x => x.id); }); + migrationBuilder.CreateTable( + name: "LicensingMetadata", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + data = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_licensing_metadata", x => x.id); + }); + migrationBuilder.CreateTable( name: "MessageBodies", columns: table => new @@ -298,6 +328,25 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("p_k_subscriptions", x => x.id); }); + migrationBuilder.CreateTable( + name: "ThroughputEndpoint", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + endpoint_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + throughput_source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + sanitized_endpoint_name = table.Column(type: "text", nullable: true), + endpoint_indicators = table.Column(type: "text", nullable: true), + user_indicator = table.Column(type: "text", nullable: true), + scope = table.Column(type: "text", nullable: true), + last_collected_data = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_endpoints", x => x.id); + }); + migrationBuilder.CreateTable( name: "TrialLicense", columns: table => new @@ -331,6 +380,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "CustomChecks", column: "status"); + migrationBuilder.CreateIndex( + name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", + table: "DailyThroughput", + columns: new[] { "endpoint_name", "throughput_source", "date" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_EventLogItems_raised_at", table: "EventLogItems", @@ -422,6 +477,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "GroupComments", column: "group_id"); + migrationBuilder.CreateIndex( + name: "IX_LicensingMetadata_key", + table: "LicensingMetadata", + column: "key", + unique: true); + migrationBuilder.CreateIndex( name: "IX_RetryBatches_retry_session_id", table: "RetryBatches", @@ -447,6 +508,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Subscriptions", columns: new[] { "message_type_type_name", "message_type_version" }, unique: true); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "endpoint_name", "throughput_source" }, + unique: true); } /// @@ -458,6 +525,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "CustomChecks"); + migrationBuilder.DropTable( + name: "DailyThroughput"); + migrationBuilder.DropTable( name: "EndpointSettings"); @@ -482,6 +552,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "KnownEndpoints"); + migrationBuilder.DropTable( + name: "LicensingMetadata"); + migrationBuilder.DropTable( name: "MessageBodies"); @@ -506,6 +579,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "Subscriptions"); + migrationBuilder.DropTable( + name: "ThroughputEndpoint"); + migrationBuilder.DropTable( name: "TrialLicense"); } diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs index 231b1b5a84..28225db1b0 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -144,6 +144,44 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CustomChecks", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("MessageCount") + .HasColumnType("bigint") + .HasColumnName("message_count"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.HasKey("Id") + .HasName("p_k_throughput"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -476,6 +514,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("data"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.HasKey("Id") + .HasName("p_k_licensing_metadata"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => { b.Property("Id") @@ -721,6 +789,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("text") + .HasColumnName("endpoint_indicators"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("LastCollectedData") + .HasColumnType("date") + .HasColumnName("last_collected_data"); + + b.Property("SanitizedEndpointName") + .HasColumnType("text") + .HasColumnName("sanitized_endpoint_name"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.Property("UserIndicator") + .HasColumnType("text") + .HasColumnName("user_indicator"); + + b.HasKey("Id") + .HasName("p_k_endpoints"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.Designer.cs similarity index 85% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.Designer.cs index d938955ecd..eb59a55e81 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.SqlServer.Migrations { [DbContext(typeof(SqlServerDbContext))] - [Migration("20251214204341_InitialCreate")] + [Migration("20251215071340_InitialCreate")] partial class InitialCreate { /// @@ -124,6 +124,38 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("CustomChecks", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -401,6 +433,32 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => { b.Property("Id") @@ -604,6 +662,47 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("nvarchar(max)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasColumnType("nvarchar(max)"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.cs similarity index 86% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.cs index 5ad61428b3..1bba9bb2e0 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251214204341_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.cs @@ -52,6 +52,22 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_CustomChecks", x => x.Id); }); + migrationBuilder.CreateTable( + name: "DailyThroughput", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EndpointName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ThroughputSource = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Date = table.Column(type: "date", nullable: false), + MessageCount = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailyThroughput", x => x.Id); + }); + migrationBuilder.CreateTable( name: "EndpointSettings", columns: table => new @@ -181,6 +197,20 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_KnownEndpoints", x => x.Id); }); + migrationBuilder.CreateTable( + name: "LicensingMetadata", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Key = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Data = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LicensingMetadata", x => x.Id); + }); + migrationBuilder.CreateTable( name: "MessageBodies", columns: table => new @@ -297,6 +327,25 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_Subscriptions", x => x.Id); }); + migrationBuilder.CreateTable( + name: "ThroughputEndpoint", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EndpointName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ThroughputSource = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + SanitizedEndpointName = table.Column(type: "nvarchar(max)", nullable: true), + EndpointIndicators = table.Column(type: "nvarchar(max)", nullable: true), + UserIndicator = table.Column(type: "nvarchar(max)", nullable: true), + Scope = table.Column(type: "nvarchar(max)", nullable: true), + LastCollectedData = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ThroughputEndpoint", x => x.Id); + }); + migrationBuilder.CreateTable( name: "TrialLicense", columns: table => new @@ -330,6 +379,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "CustomChecks", column: "Status"); + migrationBuilder.CreateIndex( + name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", + table: "DailyThroughput", + columns: new[] { "EndpointName", "ThroughputSource", "Date" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_EventLogItems_RaisedAt", table: "EventLogItems", @@ -421,6 +476,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "GroupComments", column: "GroupId"); + migrationBuilder.CreateIndex( + name: "IX_LicensingMetadata_Key", + table: "LicensingMetadata", + column: "Key", + unique: true); + migrationBuilder.CreateIndex( name: "IX_RetryBatches_RetrySessionId", table: "RetryBatches", @@ -446,6 +507,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Subscriptions", columns: new[] { "MessageTypeTypeName", "MessageTypeVersion" }, unique: true); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "EndpointName", "ThroughputSource" }, + unique: true); } /// @@ -457,6 +524,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "CustomChecks"); + migrationBuilder.DropTable( + name: "DailyThroughput"); + migrationBuilder.DropTable( name: "EndpointSettings"); @@ -481,6 +551,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "KnownEndpoints"); + migrationBuilder.DropTable( + name: "LicensingMetadata"); + migrationBuilder.DropTable( name: "MessageBodies"); @@ -505,6 +578,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "Subscriptions"); + migrationBuilder.DropTable( + name: "ThroughputEndpoint"); + migrationBuilder.DropTable( name: "TrialLicense"); } diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs index 2754f2d6ac..2281aa3334 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -121,6 +121,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CustomChecks", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -398,6 +430,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.MessageBodyEntity", b => { b.Property("Id") @@ -601,6 +659,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Subscriptions", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("nvarchar(max)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasColumnType("nvarchar(max)"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.sln b/src/ServiceControl.sln index c6063c8dff..6301b9f260 100644 --- a/src/ServiceControl.sln +++ b/src/ServiceControl.sln @@ -188,8 +188,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Hosting", "S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetupProcessFake", "SetupProcessFake\SetupProcessFake.csproj", "{5837F789-69B9-44BE-B114-3A2880F06CAB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql", "ServiceControl.Persistence.Sql\ServiceControl.Persistence.Sql.csproj", "{07D7A850-3164-4C27-BE22-FD8A97C06EF3}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.Core", "ServiceControl.Persistence.Sql.Core\ServiceControl.Persistence.Sql.Core.csproj", "{7C7239A8-E56B-4A89-9028-80B2A416E989}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.SqlServer", "ServiceControl.Persistence.Sql.SqlServer\ServiceControl.Persistence.Sql.SqlServer.csproj", "{B1177EF2-9022-49D8-B282-DDF494B79CFF}" @@ -1042,18 +1040,6 @@ Global {5837F789-69B9-44BE-B114-3A2880F06CAB}.Release|x64.Build.0 = Release|Any CPU {5837F789-69B9-44BE-B114-3A2880F06CAB}.Release|x86.ActiveCfg = Release|Any CPU {5837F789-69B9-44BE-B114-3A2880F06CAB}.Release|x86.Build.0 = Release|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x64.ActiveCfg = Debug|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x64.Build.0 = Debug|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x86.ActiveCfg = Debug|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Debug|x86.Build.0 = Debug|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|Any CPU.Build.0 = Release|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x64.ActiveCfg = Release|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x64.Build.0 = Release|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.ActiveCfg = Release|Any CPU - {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.Build.0 = Release|Any CPU {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|Any CPU.Build.0 = Debug|Any CPU {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1223,7 +1209,6 @@ Global {18DBEEF5-42EE-4C1D-A05B-87B21C067D53} = {E0E45F22-35E3-4AD8-B09E-EFEA5A2F18EE} {481032A1-1106-4C6C-B75E-512F2FB08882} = {9AF9D3C7-E859-451B-BA4D-B954D289213A} {5837F789-69B9-44BE-B114-3A2880F06CAB} = {927A078A-E271-4878-A153-86D71AE510E2} - {07D7A850-3164-4C27-BE22-FD8A97C06EF3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} {7C7239A8-E56B-4A89-9028-80B2A416E989} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} {B1177EF2-9022-49D8-B282-DDF494B79CFF} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} {7A42C8BE-01C9-42F3-B15B-9365940D3FC3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} From e955cfc00b920c2dbd6d4ad82fdb569a01e1f39a Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 16 Dec 2025 11:42:24 +1000 Subject: [PATCH 15/16] Remove statistics from failed message Also added headers to the serialised entity --- .../Entities/FailedMessageEntity.cs | 6 +- .../FailedMessageConfiguration.cs | 6 +- .../EditFailedMessagesManager.cs | 29 ++++-- .../ErrorMessageDataStore.ViewMapping.cs | 18 +--- .../Implementation/ErrorMessageDataStore.cs | 4 +- .../RecoverabilityIngestionUnitOfWork.cs | 99 +++++-------------- 6 files changed, 48 insertions(+), 114 deletions(-) diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs index bf44d025c1..4e161dad1c 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs @@ -12,6 +12,7 @@ public class FailedMessageEntity // JSON columns for complex nested data public string ProcessingAttemptsJson { get; set; } = null!; public string FailureGroupsJson { get; set; } = null!; + public string HeadersJson { get; set; } = null!; // Denormalized fields from FailureGroups for efficient filtering // PrimaryFailureGroupId is the first group ID from FailureGroupsJson array @@ -29,9 +30,4 @@ public class FailedMessageEntity public int? NumberOfProcessingAttempts { get; set; } public DateTime? LastProcessedAt { get; set; } public string? ConversationId { get; set; } - - // Performance metrics for sorting and filtering - public TimeSpan? CriticalTime { get; set; } - public TimeSpan? ProcessingTime { get; set; } - public TimeSpan? DeliveryTime { get; set; } } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs index 29e62b8854..4d0668d187 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs @@ -15,6 +15,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Status).IsRequired(); builder.Property(e => e.ProcessingAttemptsJson).IsRequired(); builder.Property(e => e.FailureGroupsJson).IsRequired(); + builder.Property(e => e.HeadersJson).IsRequired(); // Denormalized query fields from FailureGroups builder.Property(e => e.PrimaryFailureGroupId).HasMaxLength(200); @@ -61,10 +62,5 @@ public void Configure(EntityTypeBuilder builder) // SINGLE-COLUMN INDEXES: Keep for specific lookup cases builder.HasIndex(e => e.MessageId); - - // PERFORMANCE METRICS INDEXES: For sorting operations - builder.HasIndex(e => e.CriticalTime); - builder.HasIndex(e => e.ProcessingTime); - builder.HasIndex(e => e.DeliveryTime); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs index 840e25897a..dde946d3b8 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs @@ -9,6 +9,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.MessageFailures; +using ServiceControl.Operations; using ServiceControl.Persistence; class EditFailedMessagesManager( @@ -51,6 +52,18 @@ class EditFailedMessagesManager( public async Task UpdateFailedMessage(FailedMessage failedMessage) { + T? GetMetadata(FailedMessage.ProcessingAttempt lastAttempt, string key) + { + if (lastAttempt.MessageMetadata.TryGetValue(key, out var value)) + { + return (T?)value; + } + else + { + return default; + } + } + var entity = await dbContext.FailedMessages .FirstOrDefaultAsync(m => m.Id == Guid.Parse(failedMessage.Id)); @@ -65,20 +78,20 @@ public async Task UpdateFailedMessage(FailedMessage failedMessage) var lastAttempt = failedMessage.ProcessingAttempts.LastOrDefault(); if (lastAttempt != null) { + entity.HeadersJson = JsonSerializer.Serialize(lastAttempt.Headers, JsonOptions); + var messageType = GetMetadata(lastAttempt, "MessageType"); + var sendingEndpoint = GetMetadata(lastAttempt, "SendingEndpoint"); + var receivingEndpoint = GetMetadata(lastAttempt, "ReceivingEndpoint"); + entity.MessageId = lastAttempt.MessageId; - entity.MessageType = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.EnclosedMessageTypes"); + entity.MessageType = messageType; entity.TimeSent = lastAttempt.AttemptedAt; - entity.SendingEndpointName = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.OriginatingEndpoint"); - entity.ReceivingEndpointName = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.ProcessingEndpoint"); + entity.SendingEndpointName = sendingEndpoint?.Name; + entity.ReceivingEndpointName = receivingEndpoint?.Name; entity.ExceptionType = lastAttempt.FailureDetails?.Exception?.ExceptionType; entity.ExceptionMessage = lastAttempt.FailureDetails?.Exception?.Message; entity.QueueAddress = lastAttempt.Headers?.GetValueOrDefault("NServiceBus.FailedQ"); entity.LastProcessedAt = lastAttempt.AttemptedAt; - - // Extract performance metrics from metadata - entity.CriticalTime = lastAttempt.MessageMetadata?.TryGetValue("CriticalTime", out var ct) == true && ct is TimeSpan ctSpan ? ctSpan : null; - entity.ProcessingTime = lastAttempt.MessageMetadata?.TryGetValue("ProcessingTime", out var pt) == true && pt is TimeSpan ptSpan ? ptSpan : null; - entity.DeliveryTime = lastAttempt.MessageMetadata?.TryGetValue("DeliveryTime", out var dt) == true && dt is TimeSpan dtSpan ? dtSpan : null; } entity.NumberOfProcessingAttempts = failedMessage.ProcessingAttempts.Count; diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs index c0eb24c6a1..5a31cd2577 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs @@ -33,15 +33,6 @@ internal static IQueryable ApplySorting(IQueryable isDescending ? query.OrderByDescending(fm => fm.MessageType) : query.OrderBy(fm => fm.MessageType), - "critical_time" => isDescending - ? query.OrderByDescending(fm => fm.CriticalTime) - : query.OrderBy(fm => fm.CriticalTime), - "delivery_time" => isDescending - ? query.OrderByDescending(fm => fm.DeliveryTime) - : query.OrderBy(fm => fm.DeliveryTime), - "processing_time" => isDescending - ? query.OrderByDescending(fm => fm.ProcessingTime) - : query.OrderBy(fm => fm.ProcessingTime), "processed_at" => isDescending ? query.OrderByDescending(fm => fm.LastProcessedAt) : query.OrderBy(fm => fm.LastProcessedAt), @@ -100,15 +91,13 @@ internal static MessagesView CreateMessagesView(FailedMessageEntity entity) { var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? []; var lastAttempt = processingAttempts.LastOrDefault(); + var headers = JsonSerializer.Deserialize>(entity.HeadersJson) ?? []; // Extract metadata from the last processing attempt (matching RavenDB implementation) var metadata = lastAttempt?.MessageMetadata; var isSystemMessage = metadata?.TryGetValue("IsSystemMessage", out var isSystem) == true && isSystem is bool b && b; var bodySize = metadata?.TryGetValue("ContentLength", out var size) == true && size is int contentLength ? contentLength : 0; - var criticalTime = metadata?.TryGetValue("CriticalTime", out var ct) == true && ct is JsonElement ctJson && TimeSpan.TryParse(ctJson.GetString(), out var parsedCt) ? parsedCt : TimeSpan.Zero; - var processingTime = metadata?.TryGetValue("ProcessingTime", out var pt) == true && pt is JsonElement ptJson && TimeSpan.TryParse(ptJson.GetString(), out var parsedPt) ? parsedPt : TimeSpan.Zero; - var deliveryTime = metadata?.TryGetValue("DeliveryTime", out var dt) == true && dt is JsonElement dtJson && TimeSpan.TryParse(dtJson.GetString(), out var parsedDt) ? parsedDt : TimeSpan.Zero; var messageIntent = metadata?.TryGetValue("MessageIntent", out var mi) == true && mi is JsonElement miJson && Enum.TryParse(miJson.GetString(), out var parsedMi) ? parsedMi : MessageIntent.Send; // Extract endpoint details from metadata (stored during ingestion) @@ -160,12 +149,9 @@ internal static MessagesView CreateMessagesView(FailedMessageEntity entity) ReceivingEndpoint = receivingEndpoint, TimeSent = entity.TimeSent, ProcessedAt = entity.LastProcessedAt ?? DateTime.MinValue, - CriticalTime = criticalTime, - ProcessingTime = processingTime, - DeliveryTime = deliveryTime, IsSystemMessage = isSystemMessage, ConversationId = entity.ConversationId, - Headers = lastAttempt?.Headers?.Select(h => new KeyValuePair(h.Key, h.Value)) ?? [], + Headers = headers.Select(h => new KeyValuePair(h.Key, h.Value)), Status = status, MessageIntent = messageIntent, BodyUrl = $"/api/errors/{entity.UniqueMessageId}/body", diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs index f71400903b..d45640d384 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -90,6 +90,7 @@ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessage Status = failedMessage.Status, ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts), FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups), + HeadersJson = JsonSerializer.Serialize(lastAttempt?.Headers ?? []), PrimaryFailureGroupId = failedMessage.FailureGroups.Count > 0 ? failedMessage.FailureGroups[0].Id : null, // Extract denormalized fields from last processing attempt if available @@ -104,9 +105,6 @@ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessage NumberOfProcessingAttempts = failedMessage.ProcessingAttempts.Count, LastProcessedAt = lastAttempt?.AttemptedAt, ConversationId = lastAttempt?.Headers?.GetValueOrDefault("NServiceBus.ConversationId"), - CriticalTime = lastAttempt?.MessageMetadata?.TryGetValue("CriticalTime", out var ct) == true && ct is TimeSpan ctSpan ? ctSpan : null, - ProcessingTime = lastAttempt?.MessageMetadata?.TryGetValue("ProcessingTime", out var pt) == true && pt is TimeSpan ptSpan ? ptSpan : null, - DeliveryTime = lastAttempt?.MessageMetadata?.TryGetValue("DeliveryTime", out var dt) == true && dt is TimeSpan dtSpan ? dtSpan : null }; dbContext.FailedMessages.Add(entity); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs index 157046d65c..6840638254 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Mime; using System.Text.Json; using System.Threading.Tasks; using Entities; @@ -21,8 +22,20 @@ class RecoverabilityIngestionUnitOfWork(IngestionUnitOfWork parent) : IRecoverab public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMessage.ProcessingAttempt processingAttempt, List groups) { + T? GetMetadata(string key) + { + if (processingAttempt.MessageMetadata.TryGetValue(key, out var value)) + { + return (T?)value; + } + else + { + return default; + } + } + var uniqueMessageId = context.Headers.UniqueId(); - var contentType = GetContentType(context.Headers, "text/plain"); + var contentType = GetContentType(context.Headers, MediaTypeNames.Text.Plain); var bodySize = context.Body.Length; // Add metadata to the processing attempt @@ -30,30 +43,14 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe processingAttempt.MessageMetadata.Add("ContentLength", bodySize); processingAttempt.MessageMetadata.Add("BodyUrl", $"/messages/{uniqueMessageId}/body"); - // Store endpoint details in metadata for efficient retrieval - var sendingEndpoint = ExtractSendingEndpoint(context.Headers); - var receivingEndpoint = ExtractReceivingEndpoint(context.Headers); - - if (sendingEndpoint != null) - { - processingAttempt.MessageMetadata.Add("SendingEndpoint", sendingEndpoint); - } - - if (receivingEndpoint != null) - { - processingAttempt.MessageMetadata.Add("ReceivingEndpoint", receivingEndpoint); - } // Extract denormalized fields from headers for efficient querying - var messageType = context.Headers.TryGetValue(Headers.EnclosedMessageTypes, out var mt) ? mt?.Split(',').FirstOrDefault()?.Trim() : null; - var timeSent = context.Headers.TryGetValue(Headers.TimeSent, out var ts) && DateTimeOffset.TryParse(ts, out var parsedTime) ? parsedTime.UtcDateTime : (DateTime?)null; - var queueAddress = context.Headers.TryGetValue("NServiceBus.FailedQ", out var qa) ? qa : null; - var conversationId = context.Headers.TryGetValue(Headers.ConversationId, out var cid) ? cid : null; - - // Extract performance metrics from metadata for efficient sorting - var criticalTime = processingAttempt.MessageMetadata.TryGetValue("CriticalTime", out var ct) && ct is TimeSpan ctSpan ? (TimeSpan?)ctSpan : null; - var processingTime = processingAttempt.MessageMetadata.TryGetValue("ProcessingTime", out var pt) && pt is TimeSpan ptSpan ? (TimeSpan?)ptSpan : null; - var deliveryTime = processingAttempt.MessageMetadata.TryGetValue("DeliveryTime", out var dt) && dt is TimeSpan dtSpan ? (TimeSpan?)dtSpan : null; + var messageType = GetMetadata("MessageType"); + var timeSent = GetMetadata("TimeSent"); + var queueAddress = context.Headers.GetValueOrDefault("NServiceBus.FailedQ"); + var conversationId = GetMetadata("ConversationId"); + var sendingEndpoint = GetMetadata("SendingEndpoint"); + var receivingEndpoint = GetMetadata("ReceivingEndpoint"); // Load existing message to merge attempts list var existingMessage = await parent.DbContext.FailedMessages @@ -81,6 +78,7 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe existingMessage.Status = FailedMessageStatus.Unresolved; existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts); existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups); + existingMessage.HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers); existingMessage.PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null; existingMessage.MessageId = processingAttempt.MessageId; existingMessage.MessageType = messageType; @@ -93,9 +91,6 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe existingMessage.NumberOfProcessingAttempts = attempts.Count; existingMessage.LastProcessedAt = processingAttempt.AttemptedAt; existingMessage.ConversationId = conversationId; - existingMessage.CriticalTime = criticalTime; - existingMessage.ProcessingTime = processingTime; - existingMessage.DeliveryTime = deliveryTime; } else { @@ -110,6 +105,7 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe Status = FailedMessageStatus.Unresolved, ProcessingAttemptsJson = JsonSerializer.Serialize(attempts), FailureGroupsJson = JsonSerializer.Serialize(groups), + HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers), PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, MessageId = processingAttempt.MessageId, MessageType = messageType, @@ -122,9 +118,6 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe NumberOfProcessingAttempts = attempts.Count, LastProcessedAt = processingAttempt.AttemptedAt, ConversationId = conversationId, - CriticalTime = criticalTime, - ProcessingTime = processingTime, - DeliveryTime = deliveryTime }; parent.DbContext.FailedMessages.Add(failedMessageEntity); } @@ -193,52 +186,4 @@ async Task StoreMessageBody(string uniqueMessageId, ReadOnlyMemory body, s static string GetContentType(IReadOnlyDictionary headers, string defaultContentType) => headers.TryGetValue(Headers.ContentType, out var contentType) ? contentType : defaultContentType; - - static EndpointDetails? ExtractSendingEndpoint(IReadOnlyDictionary headers) - { - var endpoint = new EndpointDetails(); - - if (headers.TryGetValue("NServiceBus.OriginatingEndpoint", out var name)) - { - endpoint.Name = name; - } - - if (headers.TryGetValue("NServiceBus.OriginatingMachine", out var host)) - { - endpoint.Host = host; - } - - if (headers.TryGetValue("NServiceBus.OriginatingHostId", out var hostId) && Guid.TryParse(hostId, out var parsedHostId)) - { - endpoint.HostId = parsedHostId; - } - - return !string.IsNullOrEmpty(endpoint.Name) ? endpoint : null; - } - - static EndpointDetails? ExtractReceivingEndpoint(IReadOnlyDictionary headers) - { - var endpoint = new EndpointDetails(); - - if (headers.TryGetValue("NServiceBus.ProcessingEndpoint", out var name)) - { - endpoint.Name = name; - } - - if (headers.TryGetValue("NServiceBus.HostDisplayName", out var host)) - { - endpoint.Host = host; - } - else if (headers.TryGetValue("NServiceBus.ProcessingMachine", out var machine)) - { - endpoint.Host = machine; - } - - if (headers.TryGetValue("NServiceBus.HostId", out var hostId) && Guid.TryParse(hostId, out var parsedHostId)) - { - endpoint.HostId = parsedHostId; - } - - return !string.IsNullOrEmpty(endpoint.Name) ? endpoint : null; - } } From eabf6334b734905c8c20c116f33f9e584a60fc34 Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 16 Dec 2025 12:02:15 +1000 Subject: [PATCH 16/16] Use proper db types for json --- .../Entities/EventLogItemEntity.cs | 2 +- .../EventLogItemConfiguration.cs | 3 +- ...IntegrationDispatchRequestConfiguration.cs | 2 +- .../FailedErrorImportConfiguration.cs | 2 +- .../FailedMessageConfiguration.cs | 6 +-- .../MessageRedirectsConfiguration.cs | 1 + .../NotificationsSettingsConfiguration.cs | 2 +- .../RetryBatchConfiguration.cs | 2 +- .../RetryHistoryConfiguration.cs | 4 +- .../SubscriptionConfiguration.cs | 2 +- .../EditFailedMessagesManager.cs | 17 +++---- .../ErrorMessageDataStore.FailureGroups.cs | 9 ++-- .../ErrorMessageDataStore.MessageQueries.cs | 7 +-- .../ErrorMessageDataStore.ViewMapping.cs | 19 +++---- .../Implementation/ErrorMessageDataStore.cs | 13 ++--- .../Implementation/EventLogDataStore.cs | 4 +- .../ExternalIntegrationRequestsDataStore.cs | 11 ++-- .../FailedErrorImportDataStore.cs | 9 +--- .../Implementation/GroupsDataStore.cs | 11 ++-- .../Implementation/LicensingDataStore.cs | 5 +- .../MessageRedirectsDataStore.cs | 5 +- .../Implementation/NotificationsManager.cs | 9 +--- .../Implementation/RetryBatchesManager.cs | 17 +++---- .../Implementation/RetryDocumentDataStore.cs | 15 ++---- .../Implementation/RetryHistoryDataStore.cs | 23 ++++----- .../ServiceControlSubscriptionStorage.cs | 13 ++--- .../RecoverabilityIngestionUnitOfWork.cs | 14 ++--- .../JsonSerializationOptions.cs | 12 +++++ ... 20251216015935_InitialCreate.Designer.cs} | 45 +++++++--------- ...ate.cs => 20251216015935_InitialCreate.cs} | 44 +++++----------- .../Migrations/MySqlDbContextModelSnapshot.cs | 43 ++++++---------- .../MySqlDbContext.cs | 13 ++++- ... 20251216015817_InitialCreate.Designer.cs} | 51 +++++++------------ ...ate.cs => 20251216015817_InitialCreate.cs} | 43 +++++----------- .../PostgreSqlDbContextModelSnapshot.cs | 49 +++++++----------- ... 20251216020009_InitialCreate.Designer.cs} | 25 +++------ ...ate.cs => 20251216020009_InitialCreate.cs} | 23 ++------- .../SqlServerDbContextModelSnapshot.cs | 23 +++------ .../SqlServerDbContext.cs | 13 ++++- 39 files changed, 247 insertions(+), 364 deletions(-) create mode 100644 src/ServiceControl.Persistence.Sql.Core/Infrastructure/JsonSerializationOptions.cs rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20251215071318_InitialCreate.Designer.cs => 20251216015935_InitialCreate.Designer.cs} (95%) rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20251215071318_InitialCreate.cs => 20251216015935_InitialCreate.cs} (96%) rename src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/{20251215071329_InitialCreate.Designer.cs => 20251216015817_InitialCreate.Designer.cs} (96%) rename src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/{20251215071329_InitialCreate.cs => 20251216015817_InitialCreate.cs} (95%) rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20251215071340_InitialCreate.Designer.cs => 20251216020009_InitialCreate.Designer.cs} (97%) rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20251215071340_InitialCreate.cs => 20251216020009_InitialCreate.cs} (96%) diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs index 348c0512c1..fcc815159f 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/EventLogItemEntity.cs @@ -8,7 +8,7 @@ public class EventLogItemEntity public required string Description { get; set; } public int Severity { get; set; } public DateTime RaisedAt { get; set; } - public string? RelatedTo { get; set; } // Stored as JSON array + public string? RelatedToJson { get; set; } // Stored as JSON array public string? Category { get; set; } public string? EventType { get; set; } } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs index 83eb012b5a..a5ddc6575b 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs @@ -30,7 +30,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.EventType) .HasMaxLength(200); - builder.Property(e => e.RelatedTo) + builder.Property(e => e.RelatedToJson) + .HasColumnType("jsonb") .HasMaxLength(4000); // Index for querying by RaisedAt diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs index 84bef66b78..af17a802b1 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ExternalIntegrationDispatchRequestConfiguration.cs @@ -15,7 +15,7 @@ public void Configure(EntityTypeBuilder e.DispatchContextJson).IsRequired(); + builder.Property(e => e.DispatchContextJson).HasColumnType("jsonb").IsRequired(); builder.Property(e => e.CreatedAt).IsRequired(); builder.HasIndex(e => e.CreatedAt); diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs index cf1363e64c..e969222072 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedErrorImportConfiguration.cs @@ -11,7 +11,7 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("FailedErrorImports"); builder.HasKey(e => e.Id); builder.Property(e => e.Id).IsRequired(); - builder.Property(e => e.MessageJson).IsRequired(); + builder.Property(e => e.MessageJson).HasColumnType("jsonb").IsRequired(); builder.Property(e => e.ExceptionInfo); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs index 4d0668d187..f0a6ef63fe 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs @@ -13,9 +13,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Id).IsRequired(); builder.Property(e => e.UniqueMessageId).HasMaxLength(200).IsRequired(); builder.Property(e => e.Status).IsRequired(); - builder.Property(e => e.ProcessingAttemptsJson).IsRequired(); - builder.Property(e => e.FailureGroupsJson).IsRequired(); - builder.Property(e => e.HeadersJson).IsRequired(); + builder.Property(e => e.ProcessingAttemptsJson).HasColumnType("jsonb").IsRequired(); + builder.Property(e => e.FailureGroupsJson).HasColumnType("jsonb").IsRequired(); + builder.Property(e => e.HeadersJson).HasColumnType("jsonb").IsRequired(); // Denormalized query fields from FailureGroups builder.Property(e => e.PrimaryFailureGroupId).HasMaxLength(200); diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs index 9206b9d1de..bff5840465 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs @@ -23,6 +23,7 @@ public void Configure(EntityTypeBuilder builder) .IsRequired(); builder.Property(e => e.RedirectsJson) + .HasColumnType("jsonb") .IsRequired(); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs index ba1c34ea9c..bf4f6a24fb 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/NotificationsSettingsConfiguration.cs @@ -11,6 +11,6 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("NotificationsSettings"); builder.HasKey(e => e.Id); builder.Property(e => e.Id).IsRequired(); - builder.Property(e => e.EmailSettingsJson).IsRequired(); + builder.Property(e => e.EmailSettingsJson).HasColumnType("jsonb").IsRequired(); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs index e97de041be..8c7ee6185d 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryBatchConfiguration.cs @@ -19,7 +19,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.StartTime).IsRequired(); builder.Property(e => e.Status).IsRequired(); builder.Property(e => e.RetryType).IsRequired(); - builder.Property(e => e.FailureRetriesJson).IsRequired(); + builder.Property(e => e.FailureRetriesJson).HasColumnType("jsonb").IsRequired(); // Indexes builder.HasIndex(e => e.RetrySessionId); diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs index 6cc2f0625d..e7104f8e8b 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/RetryHistoryConfiguration.cs @@ -11,7 +11,7 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("RetryHistory"); builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasDefaultValue(1).ValueGeneratedNever(); - builder.Property(e => e.HistoricOperationsJson); - builder.Property(e => e.UnacknowledgedOperationsJson); + builder.Property(e => e.HistoricOperationsJson).HasColumnType("jsonb"); + builder.Property(e => e.UnacknowledgedOperationsJson).HasColumnType("jsonb"); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs index 3cefb5daa3..349c30a5b7 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/SubscriptionConfiguration.cs @@ -13,7 +13,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Id).HasMaxLength(100); builder.Property(e => e.MessageTypeTypeName).IsRequired().HasMaxLength(500); builder.Property(e => e.MessageTypeVersion).IsRequired(); - builder.Property(e => e.SubscribersJson).IsRequired(); + builder.Property(e => e.SubscribersJson).HasColumnType("jsonb").IsRequired(); // Unique composite index to enforce one subscription per message type/version builder.HasIndex(e => new { e.MessageTypeTypeName, e.MessageTypeVersion }).IsUnique(); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs index dde946d3b8..106b01f40a 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using DbContexts; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.MessageFailures; @@ -19,12 +20,6 @@ class EditFailedMessagesManager( string? currentEditingRequestId; FailedMessage? currentMessage; - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public async Task GetFailedMessage(string uniqueMessageId) { var entity = await dbContext.FailedMessages @@ -35,8 +30,8 @@ class EditFailedMessagesManager( return null; } - var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonOptions) ?? []; - var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonOptions) ?? []; + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; + var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; currentMessage = new FailedMessage { @@ -70,15 +65,15 @@ public async Task UpdateFailedMessage(FailedMessage failedMessage) if (entity != null) { entity.Status = failedMessage.Status; - entity.ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts, JsonOptions); - entity.FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups, JsonOptions); + entity.ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts, JsonSerializationOptions.Default); + entity.FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups, JsonSerializationOptions.Default); entity.PrimaryFailureGroupId = failedMessage.FailureGroups.Count > 0 ? failedMessage.FailureGroups[0].Id : null; // Update denormalized fields from last attempt var lastAttempt = failedMessage.ProcessingAttempts.LastOrDefault(); if (lastAttempt != null) { - entity.HeadersJson = JsonSerializer.Serialize(lastAttempt.Headers, JsonOptions); + entity.HeadersJson = JsonSerializer.Serialize(lastAttempt.Headers, JsonSerializationOptions.Default); var messageType = GetMetadata(lastAttempt, "MessageType"); var sendingEndpoint = GetMetadata(lastAttempt, "SendingEndpoint"); var receivingEndpoint = GetMetadata(lastAttempt, "ReceivingEndpoint"); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs index 73854f5f2e..3593409e8c 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using ServiceControl.MessageFailures; using ServiceControl.MessageFailures.Api; @@ -28,7 +29,7 @@ public Task> GetFailureGroupView(string groupId, s var allGroups = messages .Select(fm => { - var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; // Take the first group (which matches PrimaryFailureGroupId == groupId) var primaryGroup = groups.FirstOrDefault(); return new @@ -84,7 +85,7 @@ public Task> GetFailureGroupsByClassifier(string classif var groupedData = messages .SelectMany(fm => { - var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; return groups.Select(g => new { Group = g, @@ -123,7 +124,7 @@ public Task>> GetGroupErrors(string groupId var matchingMessages = allMessages .Where(fm => { - var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; return groups.Any(g => g.Id == groupId); }) .ToList(); @@ -161,7 +162,7 @@ public Task GetGroupErrorsCount(string groupId, string status, s var count = allMessages .Count(fm => { - var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson) ?? []; + var groups = JsonSerializer.Deserialize>(fm.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; var hasGroup = groups.Any(g => g.Id == groupId); if (!hasGroup) diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs index a04fb4c57a..620cb49a2f 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using CompositeViews.Messages; +using Infrastructure; using Microsoft.EntityFrameworkCore; using ServiceControl.MessageFailures; using ServiceControl.MessageFailures.Api; @@ -367,7 +368,7 @@ public Task ErrorLastBy(string failedMessageId) return null!; } - var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? []; + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; var lastAttempt = processingAttempts.LastOrDefault(); if (lastAttempt == null) @@ -414,8 +415,8 @@ public Task ErrorBy(string failedMessageId) Id = entity.Id.ToString(), UniqueMessageId = entity.UniqueMessageId, Status = entity.Status, - ProcessingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? [], - FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson) ?? [] + ProcessingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? [], + FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? [] }; }); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs index 5a31cd2577..ff0d298b5a 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using CompositeViews.Messages; using Entities; +using Infrastructure; using MessageFailures.Api; using NServiceBus; using ServiceControl.MessageFailures; @@ -47,7 +48,7 @@ internal static IQueryable ApplySorting(IQueryable>(entity.ProcessingAttemptsJson) ?? []; + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; var lastAttempt = processingAttempts.LastOrDefault(); // Extract endpoint details from metadata (stored during ingestion) @@ -58,12 +59,12 @@ internal static FailedMessageView CreateFailedMessageView(FailedMessageEntity en { if (lastAttempt.MessageMetadata.TryGetValue("SendingEndpoint", out var sendingObj) && sendingObj is JsonElement sendingJson) { - sendingEndpoint = JsonSerializer.Deserialize(sendingJson.GetRawText()); + sendingEndpoint = JsonSerializer.Deserialize(sendingJson.GetRawText(), JsonSerializationOptions.Default); } if (lastAttempt.MessageMetadata.TryGetValue("ReceivingEndpoint", out var receivingObj) && receivingObj is JsonElement receivingJson) { - receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText()); + receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText(), JsonSerializationOptions.Default); } } @@ -89,9 +90,9 @@ internal static FailedMessageView CreateFailedMessageView(FailedMessageEntity en internal static MessagesView CreateMessagesView(FailedMessageEntity entity) { - var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? []; + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; var lastAttempt = processingAttempts.LastOrDefault(); - var headers = JsonSerializer.Deserialize>(entity.HeadersJson) ?? []; + var headers = JsonSerializer.Deserialize>(entity.HeadersJson, JsonSerializationOptions.Default) ?? []; // Extract metadata from the last processing attempt (matching RavenDB implementation) var metadata = lastAttempt?.MessageMetadata; @@ -110,22 +111,22 @@ internal static MessagesView CreateMessagesView(FailedMessageEntity entity) { if (metadata.TryGetValue("SendingEndpoint", out var sendingObj) && sendingObj is JsonElement sendingJson) { - sendingEndpoint = JsonSerializer.Deserialize(sendingJson.GetRawText()); + sendingEndpoint = JsonSerializer.Deserialize(sendingJson.GetRawText(), JsonSerializationOptions.Default); } if (metadata.TryGetValue("ReceivingEndpoint", out var receivingObj) && receivingObj is JsonElement receivingJson) { - receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText()); + receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText(), JsonSerializationOptions.Default); } if (metadata.TryGetValue("OriginatesFromSaga", out var sagaObj) && sagaObj is JsonElement sagaJson) { - originatesFromSaga = JsonSerializer.Deserialize(sagaJson.GetRawText()); + originatesFromSaga = JsonSerializer.Deserialize(sagaJson.GetRawText(), JsonSerializationOptions.Default); } if (metadata.TryGetValue("InvokedSagas", out var sagasObj) && sagasObj is JsonElement sagasJson) { - invokedSagas = JsonSerializer.Deserialize>(sagasJson.GetRawText()); + invokedSagas = JsonSerializer.Deserialize>(sagasJson.GetRawText(), JsonSerializationOptions.Default); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs index d45640d384..2c0544db61 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.EventLog; @@ -33,8 +34,8 @@ public Task FailedMessagesFetch(Guid[] ids) Id = entity.Id.ToString(), UniqueMessageId = entity.UniqueMessageId, Status = entity.Status, - ProcessingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson) ?? [], - FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson) ?? [] + ProcessingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? [], + FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? [] }).ToArray(); }); } @@ -46,7 +47,7 @@ public Task StoreFailedErrorImport(FailedErrorImport failure) var entity = new FailedErrorImportEntity { Id = Guid.Parse(failure.Id), - MessageJson = JsonSerializer.Serialize(failure.Message), + MessageJson = JsonSerializer.Serialize(failure.Message, JsonSerializationOptions.Default), ExceptionInfo = failure.ExceptionInfo }; @@ -88,9 +89,9 @@ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessage Id = Guid.Parse(failedMessage.Id), UniqueMessageId = failedMessage.UniqueMessageId, Status = failedMessage.Status, - ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts), - FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups), - HeadersJson = JsonSerializer.Serialize(lastAttempt?.Headers ?? []), + ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts, JsonSerializationOptions.Default), + FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups, JsonSerializationOptions.Default), + HeadersJson = JsonSerializer.Serialize(lastAttempt?.Headers ?? [], JsonSerializationOptions.Default), PrimaryFailureGroupId = failedMessage.FailureGroups.Count > 0 ? failedMessage.FailureGroups[0].Id : null, // Extract denormalized fields from last processing attempt if available diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs index 29a3c1577e..49dc6448f3 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs @@ -30,7 +30,7 @@ public Task Add(EventLogItem logItem) RaisedAt = logItem.RaisedAt, Category = logItem.Category, EventType = logItem.EventType, - RelatedTo = logItem.RelatedTo != null ? JsonSerializer.Serialize(logItem.RelatedTo) : null + RelatedToJson = logItem.RelatedTo != null ? JsonSerializer.Serialize(logItem.RelatedTo, JsonSerializationOptions.Default) : null }; await dbContext.EventLogItems.AddAsync(entity); @@ -61,7 +61,7 @@ public Task Add(EventLogItem logItem) RaisedAt = entity.RaisedAt, Category = entity.Category, EventType = entity.EventType, - RelatedTo = entity.RelatedTo != null ? JsonSerializer.Deserialize>(entity.RelatedTo) : null + RelatedTo = entity.RelatedToJson != null ? JsonSerializer.Deserialize>(entity.RelatedToJson, JsonSerializationOptions.Default) : null }).ToList(); // Version could be based on the latest RaisedAt timestamp but the paging can affect this result, given that the latest may not be retrieved diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs index 9d1b8d37ab..7a243cc929 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Threading; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ServiceControl.ExternalIntegrations; @@ -17,12 +18,6 @@ public class ExternalIntegrationRequestsDataStore : DataStoreBase, IExternalInte readonly ILogger logger; readonly CancellationTokenSource tokenSource = new(); - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - Func? callback; Task? dispatcherTask; bool isDisposed; @@ -47,7 +42,7 @@ public Task StoreDispatchRequest(IEnumerable var entity = new ExternalIntegrationDispatchRequestEntity { - DispatchContextJson = JsonSerializer.Serialize(dispatchRequest.DispatchContext, JsonOptions), + DispatchContextJson = JsonSerializer.Serialize(dispatchRequest.DispatchContext, JsonSerializationOptions.Default), CreatedAt = DateTime.UtcNow }; @@ -117,7 +112,7 @@ await ExecuteWithDbContext(async dbContext => } var contexts = requests - .Select(r => JsonSerializer.Deserialize(r.DispatchContextJson, JsonOptions)!) + .Select(r => JsonSerializer.Deserialize(r.DispatchContextJson, JsonSerializationOptions.Default)!) .ToArray(); logger.LogDebug("Dispatching {EventCount} events", contexts.Length); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs index e9e272cfbe..9332b8e8df 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Threading; using System.Threading.Tasks; using System.Diagnostics; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ServiceControl.Operations; @@ -14,12 +15,6 @@ public class FailedErrorImportDataStore : DataStoreBase, IFailedErrorImportDataS { readonly ILogger logger; - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public FailedErrorImportDataStore(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider) { this.logger = logger; @@ -44,7 +39,7 @@ public Task ProcessFailedErrorImports(Func process FailedTransportMessage? transportMessage = null; try { - transportMessage = JsonSerializer.Deserialize(import.MessageJson, JsonOptions); + transportMessage = JsonSerializer.Deserialize(import.MessageJson, JsonSerializationOptions.Default); Debug.Assert(transportMessage != null, "Deserialized transport message should not be null"); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs index 8f6466cef7..fe41256002 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using ServiceControl.MessageFailures; using ServiceControl.Persistence; @@ -13,12 +14,6 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; public class GroupsDataStore : DataStoreBase, IGroupsDataStore { - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public GroupsDataStore(IServiceProvider serviceProvider) : base(serviceProvider) { } @@ -42,7 +37,7 @@ public Task> GetFailureGroupsByClassifier(string classif var allGroups = failedMessages .SelectMany(m => { - var groups = JsonSerializer.Deserialize>(m.FailureGroupsJson, JsonOptions) ?? []; + var groups = JsonSerializer.Deserialize>(m.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; return groups.Select(g => new { Group = g, ProcessedAt = m.LastProcessedAt }); }) .Where(x => x.Group.Type == classifier) @@ -127,7 +122,7 @@ public Task> GetFailureGroupsByClassifier(string classif InitialBatchSize = batchEntity.InitialBatchSize, Status = batchEntity.Status, RetryType = batchEntity.RetryType, - FailureRetries = JsonSerializer.Deserialize>(batchEntity.FailureRetriesJson, JsonOptions) ?? [] + FailureRetries = JsonSerializer.Deserialize>(batchEntity.FailureRetriesJson, JsonSerializationOptions.Default) ?? [] }; }); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs index 86c48f644e..1551732d85 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.Persistence; @@ -293,7 +294,7 @@ public async Task GetBrokerMetadata(CancellationToken cancellati { return default; } - return JsonSerializer.Deserialize(existing.Data); + return JsonSerializer.Deserialize(existing.Data, JsonSerializationOptions.Default); }); } @@ -303,7 +304,7 @@ Task SaveMetadata(string key, T data, CancellationToken cancellationToken) { var existing = await dbContext.LicensingMetadata.SingleOrDefaultAsync(m => m.Key == key, cancellationToken); - var serialized = JsonSerializer.Serialize(data); + var serialized = JsonSerializer.Serialize(data, JsonSerializationOptions.Default); if (existing is null) { diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs index 28090d2715..36ec2dd8c9 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using ServiceControl.Persistence.MessageRedirects; @@ -31,7 +32,7 @@ public Task GetOrCreate() }; } - var redirects = JsonSerializer.Deserialize>(entity.RedirectsJson) ?? []; + var redirects = JsonSerializer.Deserialize>(entity.RedirectsJson, JsonSerializationOptions.Default) ?? []; return new MessageRedirectsCollection { @@ -46,7 +47,7 @@ public Task Save(MessageRedirectsCollection redirects) { return ExecuteWithDbContext(async dbContext => { - var redirectsJson = JsonSerializer.Serialize(redirects.Redirects); + var redirectsJson = JsonSerializer.Serialize(redirects.Redirects, JsonSerializationOptions.Default); var newETag = Guid.NewGuid().ToString(); var newLastModified = DateTime.UtcNow; diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs index f99fa7d2e7..73a2a56afe 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using DbContexts; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Notifications; @@ -13,12 +14,6 @@ class NotificationsManager(IServiceScope scope) : INotificationsManager { readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public async Task LoadSettings(TimeSpan? cacheTimeout = null) { var entity = await dbContext.NotificationsSettings @@ -35,7 +30,7 @@ public async Task LoadSettings(TimeSpan? cacheTimeout = n }; } - var emailSettings = JsonSerializer.Deserialize(entity.EmailSettingsJson, JsonOptions) ?? new EmailNotifications(); + var emailSettings = JsonSerializer.Deserialize(entity.EmailSettingsJson, JsonSerializationOptions.Default) ?? new EmailNotifications(); return new NotificationsSettings { diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs index 5a08a846f9..8952d8df8c 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs @@ -8,6 +8,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Threading.Tasks; using DbContexts; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -23,12 +24,6 @@ class RetryBatchesManager( readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); readonly List deferredActions = []; - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public void Delete(RetryBatch retryBatch) { deferredActions.Add(() => @@ -130,7 +125,7 @@ await dbContext.RetryBatches } // Pre-load the related failure retries for the "Include" pattern - var failureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonOptions) ?? []; + var failureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonSerializationOptions.Default) ?? []; if (failureRetries.Count > 0) { var retryGuids = failureRetries.Select(Guid.Parse).ToList(); @@ -170,7 +165,7 @@ public async Task GetOrCreateMessageRedirectsCollect if (entity != null) { - var collection = JsonSerializer.Deserialize(entity.RedirectsJson, JsonOptions) + var collection = JsonSerializer.Deserialize(entity.RedirectsJson, JsonSerializationOptions.Default) ?? new MessageRedirectsCollection(); // Set metadata properties (ETag and LastModified are not available in EF Core the same way as RavenDB) @@ -224,7 +219,7 @@ static RetryBatch ToRetryBatch(RetryBatchEntity entity) InitialBatchSize = entity.InitialBatchSize, Status = entity.Status, RetryType = entity.RetryType, - FailureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonOptions) ?? [] + FailureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonSerializationOptions.Default) ?? [] }; } @@ -242,8 +237,8 @@ static FailedMessageRetry ToFailedMessageRetry(FailedMessageRetryEntity entity) static FailedMessage ToFailedMessage(FailedMessageEntity entity) { // This is a simplified conversion - we'll need to expand this when implementing IErrorMessageDataStore - var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonOptions) ?? []; - var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonOptions) ?? []; + var processingAttempts = JsonSerializer.Deserialize>(entity.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; + var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; return new FailedMessage { diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs index dd080a97d4..646b55cfc0 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ServiceControl.MessageFailures; @@ -17,12 +18,6 @@ public class RetryDocumentDataStore : DataStoreBase, IRetryDocumentDataStore { readonly ILogger logger; - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public RetryDocumentDataStore( IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider) @@ -110,7 +105,7 @@ public Task CreateBatchDocument( Last = last, InitialBatchSize = failedMessageRetryIds.Length, RetrySessionId = retrySessionId, - FailureRetriesJson = JsonSerializer.Serialize(failedMessageRetryIds, JsonOptions), + FailureRetriesJson = JsonSerializer.Serialize(failedMessageRetryIds, JsonSerializationOptions.Default), Status = RetryBatchStatus.MarkingDocuments }; @@ -144,7 +139,7 @@ public Task>> QueryOrphanedBatches(string retrySes InitialBatchSize = entity.InitialBatchSize, Status = entity.Status, RetryType = entity.RetryType, - FailureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonOptions) ?? [] + FailureRetries = JsonSerializer.Deserialize>(entity.FailureRetriesJson, JsonSerializationOptions.Default) ?? [] }).ToList(); return new QueryResult>(result, new QueryStatsInfo(string.Empty, result.Count, false)); @@ -262,7 +257,7 @@ public Task GetBatchesForFailureGroup(string groupId, string groupTitle, string foreach (var message in messages) { - var groups = JsonSerializer.Deserialize>(message.FailureGroupsJson, JsonOptions) ?? []; + var groups = JsonSerializer.Deserialize>(message.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; if (groups.Any(g => g.Id == groupId)) { var timeOfFailure = message.LastProcessedAt ?? DateTime.UtcNow; @@ -292,7 +287,7 @@ public Task GetBatchesForFailureGroup(string groupId, string groupTitle, string foreach (var message in messages) { - var groups = JsonSerializer.Deserialize>(message.FailureGroupsJson, JsonOptions) ?? []; + var groups = JsonSerializer.Deserialize>(message.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; var group = groups.FirstOrDefault(g => g.Id == groupId); if (group != null) { diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs index b6960a1062..05171b740e 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Text.Json; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using ServiceControl.Persistence; using ServiceControl.Recoverability; @@ -14,12 +15,6 @@ public class RetryHistoryDataStore : DataStoreBase, IRetryHistoryDataStore { const int SingletonId = 1; - static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - public RetryHistoryDataStore(IServiceProvider serviceProvider) : base(serviceProvider) { } @@ -39,11 +34,11 @@ public Task GetRetryHistory() var historicOperations = string.IsNullOrEmpty(entity.HistoricOperationsJson) ? [] - : JsonSerializer.Deserialize>(entity.HistoricOperationsJson, JsonOptions) ?? []; + : JsonSerializer.Deserialize>(entity.HistoricOperationsJson, JsonSerializationOptions.Default) ?? []; var unacknowledgedOperations = string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson) ? [] - : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonOptions) ?? []; + : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonSerializationOptions.Default) ?? []; return new RetryHistory { @@ -71,11 +66,11 @@ public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, // Deserialize existing data var historicOperations = string.IsNullOrEmpty(entity.HistoricOperationsJson) ? [] - : JsonSerializer.Deserialize>(entity.HistoricOperationsJson, JsonOptions) ?? []; + : JsonSerializer.Deserialize>(entity.HistoricOperationsJson, JsonSerializationOptions.Default) ?? []; var unacknowledgedOperations = string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson) ? [] - : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonOptions) ?? []; + : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonSerializationOptions.Default) ?? []; // Add to history (mimicking RetryHistory.AddToHistory) var historicOperation = new HistoricRetryOperation @@ -115,8 +110,8 @@ public Task RecordRetryOperationCompleted(string requestId, RetryType retryType, } // Serialize and save - entity.HistoricOperationsJson = JsonSerializer.Serialize(historicOperations, JsonOptions); - entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonOptions); + entity.HistoricOperationsJson = JsonSerializer.Serialize(historicOperations, JsonSerializationOptions.Default); + entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonSerializationOptions.Default); await dbContext.SaveChangesAsync(); }); @@ -133,7 +128,7 @@ public Task AcknowledgeRetryGroup(string groupId) return false; } - var unacknowledgedOperations = JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonOptions) ?? []; + var unacknowledgedOperations = JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonSerializationOptions.Default) ?? []; // Find and remove matching operations var removed = unacknowledgedOperations.RemoveAll(x => @@ -141,7 +136,7 @@ public Task AcknowledgeRetryGroup(string groupId) if (removed > 0) { - entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonOptions); + entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonSerializationOptions.Default); await dbContext.SaveChangesAsync(); return true; } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs index 8eff61b361..732b3419b5 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs @@ -9,6 +9,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Threading; using System.Threading.Tasks; using Entities; +using Infrastructure; using Microsoft.EntityFrameworkCore; using NServiceBus.Extensibility; using NServiceBus.Settings; @@ -88,17 +89,17 @@ await ExecuteWithDbContext(async dbContext => Id = subscriptionId, MessageTypeTypeName = messageType.TypeName, MessageTypeVersion = messageType.Version.Major, - SubscribersJson = JsonSerializer.Serialize(new List { subscriptionClient }) + SubscribersJson = JsonSerializer.Serialize(new List { subscriptionClient }, JsonSerializationOptions.Default) }; await dbContext.Subscriptions.AddAsync(subscription, cancellationToken); } else { - var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson) ?? []; + var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson, JsonSerializationOptions.Default) ?? []; if (!subscribers.Contains(subscriptionClient)) { subscribers.Add(subscriptionClient); - subscription.SubscribersJson = JsonSerializer.Serialize(subscribers); + subscription.SubscribersJson = JsonSerializer.Serialize(subscribers, JsonSerializationOptions.Default); } else { @@ -135,11 +136,11 @@ await ExecuteWithDbContext(async dbContext => if (subscription != null) { var subscriptionClient = CreateSubscriptionClient(subscriber); - var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson) ?? []; + var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson, JsonSerializationOptions.Default) ?? []; if (subscribers.Remove(subscriptionClient)) { - subscription.SubscribersJson = JsonSerializer.Serialize(subscribers); + subscription.SubscribersJson = JsonSerializer.Serialize(subscribers, JsonSerializationOptions.Default); await dbContext.SaveChangesAsync(cancellationToken); // Refresh lookup @@ -163,7 +164,7 @@ public Task> GetSubscriberAddressesForMessage(IEnumerabl void UpdateLookup(List subscriptions) { subscriptionsLookup = (from subscription in subscriptions - let subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson) ?? [] + let subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson, JsonSerializationOptions.Default) ?? [] from client in subscribers select new { diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs index 6840638254..0b3c730fc9 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -60,7 +60,7 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe if (existingMessage != null) { // Merge with existing attempts - attempts = JsonSerializer.Deserialize>(existingMessage.ProcessingAttemptsJson) ?? []; + attempts = JsonSerializer.Deserialize>(existingMessage.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; // De-duplicate attempts by AttemptedAt value var duplicateIndex = attempts.FindIndex(a => a.AttemptedAt == processingAttempt.AttemptedAt); @@ -76,9 +76,9 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe // Update the tracked entity existingMessage.Status = FailedMessageStatus.Unresolved; - existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts); - existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups); - existingMessage.HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers); + existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts, JsonSerializationOptions.Default); + existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups, JsonSerializationOptions.Default); + existingMessage.HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers, JsonSerializationOptions.Default); existingMessage.PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null; existingMessage.MessageId = processingAttempt.MessageId; existingMessage.MessageType = messageType; @@ -103,9 +103,9 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe Id = SequentialGuidGenerator.NewSequentialGuid(), UniqueMessageId = uniqueMessageId, Status = FailedMessageStatus.Unresolved, - ProcessingAttemptsJson = JsonSerializer.Serialize(attempts), - FailureGroupsJson = JsonSerializer.Serialize(groups), - HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers), + ProcessingAttemptsJson = JsonSerializer.Serialize(attempts, JsonSerializationOptions.Default), + FailureGroupsJson = JsonSerializer.Serialize(groups, JsonSerializationOptions.Default), + HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers, JsonSerializationOptions.Default), PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, MessageId = processingAttempt.MessageId, MessageType = messageType, diff --git a/src/ServiceControl.Persistence.Sql.Core/Infrastructure/JsonSerializationOptions.cs b/src/ServiceControl.Persistence.Sql.Core/Infrastructure/JsonSerializationOptions.cs new file mode 100644 index 0000000000..c231986162 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Infrastructure/JsonSerializationOptions.cs @@ -0,0 +1,12 @@ +namespace ServiceControl.Persistence.Sql.Core.Infrastructure; + +using System.Text.Json; + +static class JsonSerializationOptions +{ + public static readonly JsonSerializerOptions Default = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs similarity index 95% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs index 9eb4438c42..1d689bf060 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.MySQL.Migrations { [DbContext(typeof(MySqlDbContext))] - [Migration("20251215071318_InitialCreate")] + [Migration("20251216015935_InitialCreate")] partial class InitialCreate { /// @@ -191,9 +191,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RaisedAt") .HasColumnType("datetime(6)"); - b.Property("RelatedTo") + b.Property("RelatedToJson") .HasMaxLength(4000) - .HasColumnType("varchar(4000)"); + .HasColumnType("json"); b.Property("Severity") .HasColumnType("int"); @@ -218,7 +218,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("DispatchContextJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -238,7 +238,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("MessageJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -255,12 +255,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("varchar(200)"); - b.Property("CriticalTime") - .HasColumnType("time(6)"); - - b.Property("DeliveryTime") - .HasColumnType("time(6)"); - b.Property("ExceptionMessage") .HasColumnType("longtext"); @@ -270,7 +264,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FailureGroupsJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("json"); b.Property("LastProcessedAt") .HasColumnType("datetime(6)"); @@ -292,10 +290,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ProcessingAttemptsJson") .IsRequired() - .HasColumnType("longtext"); - - b.Property("ProcessingTime") - .HasColumnType("time(6)"); + .HasColumnType("json"); b.Property("QueueAddress") .HasMaxLength(500) @@ -322,14 +317,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("CriticalTime"); - - b.HasIndex("DeliveryTime"); - b.HasIndex("MessageId"); - b.HasIndex("ProcessingTime"); - b.HasIndex("UniqueMessageId") .IsUnique(); @@ -502,7 +491,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RedirectsJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -517,7 +506,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("EmailSettingsJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -553,7 +542,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FailureRetriesJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.Property("InitialBatchSize") .HasColumnType("int"); @@ -626,10 +615,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasDefaultValue(1); b.Property("HistoricOperationsJson") - .HasColumnType("longtext"); + .HasColumnType("json"); b.Property("UnacknowledgedOperationsJson") - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -652,7 +641,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("SubscribersJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs similarity index 96% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs index fe01444877..16c6ba2775 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251215071318_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs @@ -107,7 +107,7 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"), Severity = table.Column(type: "int", nullable: false), RaisedAt = table.Column(type: "datetime(6)", nullable: false), - RelatedTo = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: true) + RelatedToJson = table.Column(type: "json", maxLength: 4000, nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), Category = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), @@ -126,7 +126,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "bigint", nullable: false) .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), - DispatchContextJson = table.Column(type: "longtext", nullable: false) + DispatchContextJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), CreatedAt = table.Column(type: "datetime(6)", nullable: false) }, @@ -141,7 +141,7 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - MessageJson = table.Column(type: "longtext", nullable: false) + MessageJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), ExceptionInfo = table.Column(type: "longtext", nullable: true) .Annotation("MySql:CharSet", "utf8mb4") @@ -177,9 +177,11 @@ protected override void Up(MigrationBuilder migrationBuilder) UniqueMessageId = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), Status = table.Column(type: "int", nullable: false), - ProcessingAttemptsJson = table.Column(type: "longtext", nullable: false) + ProcessingAttemptsJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), - FailureGroupsJson = table.Column(type: "longtext", nullable: false) + FailureGroupsJson = table.Column(type: "json", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + HeadersJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), PrimaryFailureGroupId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), @@ -201,10 +203,7 @@ protected override void Up(MigrationBuilder migrationBuilder) NumberOfProcessingAttempts = table.Column(type: "int", nullable: true), LastProcessedAt = table.Column(type: "datetime(6)", nullable: true), ConversationId = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) - .Annotation("MySql:CharSet", "utf8mb4"), - CriticalTime = table.Column(type: "time(6)", nullable: true), - ProcessingTime = table.Column(type: "time(6)", nullable: true), - DeliveryTime = table.Column(type: "time(6)", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => { @@ -291,7 +290,7 @@ protected override void Up(MigrationBuilder migrationBuilder) ETag = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), LastModified = table.Column(type: "datetime(6)", nullable: false), - RedirectsJson = table.Column(type: "longtext", nullable: false) + RedirectsJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => @@ -305,7 +304,7 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - EmailSettingsJson = table.Column(type: "longtext", nullable: false) + EmailSettingsJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => @@ -350,7 +349,7 @@ protected override void Up(MigrationBuilder migrationBuilder) InitialBatchSize = table.Column(type: "int", nullable: false), RetryType = table.Column(type: "int", nullable: false), Status = table.Column(type: "int", nullable: false), - FailureRetriesJson = table.Column(type: "longtext", nullable: false) + FailureRetriesJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => @@ -379,9 +378,9 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Id = table.Column(type: "int", nullable: false, defaultValue: 1), - HistoricOperationsJson = table.Column(type: "longtext", nullable: true) + HistoricOperationsJson = table.Column(type: "json", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), - UnacknowledgedOperationsJson = table.Column(type: "longtext", nullable: true) + UnacknowledgedOperationsJson = table.Column(type: "json", nullable: true) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => @@ -399,7 +398,7 @@ protected override void Up(MigrationBuilder migrationBuilder) MessageTypeTypeName = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), MessageTypeVersion = table.Column(type: "int", nullable: false), - SubscribersJson = table.Column(type: "longtext", nullable: false) + SubscribersJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => @@ -499,16 +498,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "FailedMessages", columns: new[] { "ConversationId", "LastProcessedAt" }); - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_CriticalTime", - table: "FailedMessages", - column: "CriticalTime"); - - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_DeliveryTime", - table: "FailedMessages", - column: "DeliveryTime"); - migrationBuilder.CreateIndex( name: "IX_FailedMessages_MessageId", table: "FailedMessages", @@ -524,11 +513,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "FailedMessages", columns: new[] { "PrimaryFailureGroupId", "Status", "LastProcessedAt" }); - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_ProcessingTime", - table: "FailedMessages", - column: "ProcessingTime"); - migrationBuilder.CreateIndex( name: "IX_FailedMessages_QueueAddress_Status_LastProcessedAt", table: "FailedMessages", diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs index cfff96c939..15a01b6c39 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -188,9 +188,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RaisedAt") .HasColumnType("datetime(6)"); - b.Property("RelatedTo") + b.Property("RelatedToJson") .HasMaxLength(4000) - .HasColumnType("varchar(4000)"); + .HasColumnType("json"); b.Property("Severity") .HasColumnType("int"); @@ -215,7 +215,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DispatchContextJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -235,7 +235,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MessageJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -252,12 +252,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("varchar(200)"); - b.Property("CriticalTime") - .HasColumnType("time(6)"); - - b.Property("DeliveryTime") - .HasColumnType("time(6)"); - b.Property("ExceptionMessage") .HasColumnType("longtext"); @@ -267,7 +261,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FailureGroupsJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("json"); b.Property("LastProcessedAt") .HasColumnType("datetime(6)"); @@ -289,10 +287,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProcessingAttemptsJson") .IsRequired() - .HasColumnType("longtext"); - - b.Property("ProcessingTime") - .HasColumnType("time(6)"); + .HasColumnType("json"); b.Property("QueueAddress") .HasMaxLength(500) @@ -319,14 +314,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("CriticalTime"); - - b.HasIndex("DeliveryTime"); - b.HasIndex("MessageId"); - b.HasIndex("ProcessingTime"); - b.HasIndex("UniqueMessageId") .IsUnique(); @@ -499,7 +488,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RedirectsJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -514,7 +503,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailSettingsJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -550,7 +539,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FailureRetriesJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.Property("InitialBatchSize") .HasColumnType("int"); @@ -623,10 +612,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue(1); b.Property("HistoricOperationsJson") - .HasColumnType("longtext"); + .HasColumnType("json"); b.Property("UnacknowledgedOperationsJson") - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); @@ -649,7 +638,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SubscribersJson") .IsRequired() - .HasColumnType("longtext"); + .HasColumnType("json"); b.HasKey("Id"); diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs index db430f16b8..e2eb64d47d 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs @@ -11,6 +11,17 @@ public MySqlDbContext(DbContextOptions options) : base(options) protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) { - // MySQL-specific configurations if needed + // MySQL uses 'json' instead of 'jsonb' (PostgreSQL-specific) + // Override all jsonb column types to use 'json' + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.GetColumnType() == "jsonb") + { + property.SetColumnType("json"); + } + } + } } } diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs similarity index 96% rename from src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs index e3229ac52b..39b41cad63 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations { [DbContext(typeof(PostgreSqlDbContext))] - [Migration("20251215071329_InitialCreate")] + [Migration("20251216015817_InitialCreate")] partial class InitialCreate { /// @@ -227,10 +227,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("raised_at"); - b.Property("RelatedTo") + b.Property("RelatedToJson") .HasMaxLength(4000) - .HasColumnType("character varying(4000)") - .HasColumnName("related_to"); + .HasColumnType("jsonb") + .HasColumnName("related_to_json"); b.Property("Severity") .HasColumnType("integer") @@ -259,7 +259,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("DispatchContextJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("dispatch_context_json"); b.HasKey("Id") @@ -283,7 +283,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("MessageJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("message_json"); b.HasKey("Id") @@ -304,14 +304,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(200)") .HasColumnName("conversation_id"); - b.Property("CriticalTime") - .HasColumnType("interval") - .HasColumnName("critical_time"); - - b.Property("DeliveryTime") - .HasColumnType("interval") - .HasColumnName("delivery_time"); - b.Property("ExceptionMessage") .HasColumnType("text") .HasColumnName("exception_message"); @@ -323,9 +315,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FailureGroupsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("failure_groups_json"); + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("headers_json"); + b.Property("LastProcessedAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_processed_at"); @@ -351,13 +348,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ProcessingAttemptsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("processing_attempts_json"); - b.Property("ProcessingTime") - .HasColumnType("interval") - .HasColumnName("processing_time"); - b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("character varying(500)") @@ -390,14 +383,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("p_k_failed_messages"); - b.HasIndex("CriticalTime"); - - b.HasIndex("DeliveryTime"); - b.HasIndex("MessageId"); - b.HasIndex("ProcessingTime"); - b.HasIndex("UniqueMessageId") .IsUnique(); @@ -599,7 +586,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RedirectsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("redirects_json"); b.HasKey("Id") @@ -617,7 +604,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("EmailSettingsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("email_settings_json"); b.HasKey("Id") @@ -660,7 +647,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FailureRetriesJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("failure_retries_json"); b.Property("InitialBatchSize") @@ -748,11 +735,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("HistoricOperationsJson") - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("historic_operations_json"); b.Property("UnacknowledgedOperationsJson") - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("unacknowledged_operations_json"); b.HasKey("Id") @@ -780,7 +767,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("SubscribersJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("subscribers_json"); b.HasKey("Id") diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs similarity index 95% rename from src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs index 9b44ca6981..e272358e38 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251215071329_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs @@ -89,7 +89,7 @@ protected override void Up(MigrationBuilder migrationBuilder) description = table.Column(type: "text", nullable: false), severity = table.Column(type: "integer", nullable: false), raised_at = table.Column(type: "timestamp with time zone", nullable: false), - related_to = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + related_to_json = table.Column(type: "jsonb", maxLength: 4000, nullable: true), category = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), event_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) }, @@ -104,7 +104,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { id = table.Column(type: "bigint", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - dispatch_context_json = table.Column(type: "text", nullable: false), + dispatch_context_json = table.Column(type: "jsonb", nullable: false), created_at = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => @@ -117,7 +117,7 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { id = table.Column(type: "uuid", nullable: false), - message_json = table.Column(type: "text", nullable: false), + message_json = table.Column(type: "jsonb", nullable: false), exception_info = table.Column(type: "text", nullable: true) }, constraints: table => @@ -146,8 +146,9 @@ protected override void Up(MigrationBuilder migrationBuilder) id = table.Column(type: "uuid", nullable: false), unique_message_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), status = table.Column(type: "integer", nullable: false), - processing_attempts_json = table.Column(type: "text", nullable: false), - failure_groups_json = table.Column(type: "text", nullable: false), + processing_attempts_json = table.Column(type: "jsonb", nullable: false), + failure_groups_json = table.Column(type: "jsonb", nullable: false), + headers_json = table.Column(type: "jsonb", nullable: false), primary_failure_group_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), message_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), message_type = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), @@ -159,10 +160,7 @@ protected override void Up(MigrationBuilder migrationBuilder) queue_address = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), number_of_processing_attempts = table.Column(type: "integer", nullable: true), last_processed_at = table.Column(type: "timestamp with time zone", nullable: true), - conversation_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), - critical_time = table.Column(type: "interval", nullable: true), - processing_time = table.Column(type: "interval", nullable: true), - delivery_time = table.Column(type: "interval", nullable: true) + conversation_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) }, constraints: table => { @@ -234,7 +232,7 @@ protected override void Up(MigrationBuilder migrationBuilder) id = table.Column(type: "uuid", nullable: false), e_tag = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), last_modified = table.Column(type: "timestamp with time zone", nullable: false), - redirects_json = table.Column(type: "text", nullable: false) + redirects_json = table.Column(type: "jsonb", nullable: false) }, constraints: table => { @@ -246,7 +244,7 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { id = table.Column(type: "uuid", nullable: false), - email_settings_json = table.Column(type: "text", nullable: false) + email_settings_json = table.Column(type: "jsonb", nullable: false) }, constraints: table => { @@ -281,7 +279,7 @@ protected override void Up(MigrationBuilder migrationBuilder) initial_batch_size = table.Column(type: "integer", nullable: false), retry_type = table.Column(type: "integer", nullable: false), status = table.Column(type: "integer", nullable: false), - failure_retries_json = table.Column(type: "text", nullable: false) + failure_retries_json = table.Column(type: "jsonb", nullable: false) }, constraints: table => { @@ -306,8 +304,8 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { id = table.Column(type: "integer", nullable: false, defaultValue: 1), - historic_operations_json = table.Column(type: "text", nullable: true), - unacknowledged_operations_json = table.Column(type: "text", nullable: true) + historic_operations_json = table.Column(type: "jsonb", nullable: true), + unacknowledged_operations_json = table.Column(type: "jsonb", nullable: true) }, constraints: table => { @@ -321,7 +319,7 @@ protected override void Up(MigrationBuilder migrationBuilder) id = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), message_type_type_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), message_type_version = table.Column(type: "integer", nullable: false), - subscribers_json = table.Column(type: "text", nullable: false) + subscribers_json = table.Column(type: "jsonb", nullable: false) }, constraints: table => { @@ -411,16 +409,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "FailedMessages", columns: new[] { "conversation_id", "last_processed_at" }); - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_critical_time", - table: "FailedMessages", - column: "critical_time"); - - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_delivery_time", - table: "FailedMessages", - column: "delivery_time"); - migrationBuilder.CreateIndex( name: "IX_FailedMessages_message_id", table: "FailedMessages", @@ -436,11 +424,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "FailedMessages", columns: new[] { "primary_failure_group_id", "status", "last_processed_at" }); - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_processing_time", - table: "FailedMessages", - column: "processing_time"); - migrationBuilder.CreateIndex( name: "IX_FailedMessages_queue_address_status_last_processed_at", table: "FailedMessages", diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs index 28225db1b0..d68da971bc 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -224,10 +224,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("raised_at"); - b.Property("RelatedTo") + b.Property("RelatedToJson") .HasMaxLength(4000) - .HasColumnType("character varying(4000)") - .HasColumnName("related_to"); + .HasColumnType("jsonb") + .HasColumnName("related_to_json"); b.Property("Severity") .HasColumnType("integer") @@ -256,7 +256,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DispatchContextJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("dispatch_context_json"); b.HasKey("Id") @@ -280,7 +280,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MessageJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("message_json"); b.HasKey("Id") @@ -301,14 +301,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(200)") .HasColumnName("conversation_id"); - b.Property("CriticalTime") - .HasColumnType("interval") - .HasColumnName("critical_time"); - - b.Property("DeliveryTime") - .HasColumnType("interval") - .HasColumnName("delivery_time"); - b.Property("ExceptionMessage") .HasColumnType("text") .HasColumnName("exception_message"); @@ -320,9 +312,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FailureGroupsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("failure_groups_json"); + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("headers_json"); + b.Property("LastProcessedAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_processed_at"); @@ -348,13 +345,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProcessingAttemptsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("processing_attempts_json"); - b.Property("ProcessingTime") - .HasColumnType("interval") - .HasColumnName("processing_time"); - b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("character varying(500)") @@ -387,14 +380,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("p_k_failed_messages"); - b.HasIndex("CriticalTime"); - - b.HasIndex("DeliveryTime"); - b.HasIndex("MessageId"); - b.HasIndex("ProcessingTime"); - b.HasIndex("UniqueMessageId") .IsUnique(); @@ -596,7 +583,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RedirectsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("redirects_json"); b.HasKey("Id") @@ -614,7 +601,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailSettingsJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("email_settings_json"); b.HasKey("Id") @@ -657,7 +644,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FailureRetriesJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("failure_retries_json"); b.Property("InitialBatchSize") @@ -745,11 +732,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("HistoricOperationsJson") - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("historic_operations_json"); b.Property("UnacknowledgedOperationsJson") - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("unacknowledged_operations_json"); b.HasKey("Id") @@ -777,7 +764,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SubscribersJson") .IsRequired() - .HasColumnType("text") + .HasColumnType("jsonb") .HasColumnName("subscribers_json"); b.HasKey("Id") diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs similarity index 97% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs index eb59a55e81..f954d12a29 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.SqlServer.Migrations { [DbContext(typeof(SqlServerDbContext))] - [Migration("20251215071340_InitialCreate")] + [Migration("20251216020009_InitialCreate")] partial class InitialCreate { /// @@ -191,9 +191,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RaisedAt") .HasColumnType("datetime2"); - b.Property("RelatedTo") + b.Property("RelatedToJson") .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasColumnType("nvarchar(max)"); b.Property("Severity") .HasColumnType("int"); @@ -255,12 +255,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("nvarchar(200)"); - b.Property("CriticalTime") - .HasColumnType("time"); - - b.Property("DeliveryTime") - .HasColumnType("time"); - b.Property("ExceptionMessage") .HasColumnType("nvarchar(max)"); @@ -272,6 +266,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("LastProcessedAt") .HasColumnType("datetime2"); @@ -294,9 +292,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); - b.Property("ProcessingTime") - .HasColumnType("time"); - b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("nvarchar(500)"); @@ -322,14 +317,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("CriticalTime"); - - b.HasIndex("DeliveryTime"); - b.HasIndex("MessageId"); - b.HasIndex("ProcessingTime"); - b.HasIndex("UniqueMessageId") .IsUnique(); diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs similarity index 96% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs index 1bba9bb2e0..196eb3ac47 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251215071340_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs @@ -88,7 +88,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Description = table.Column(type: "nvarchar(max)", nullable: false), Severity = table.Column(type: "int", nullable: false), RaisedAt = table.Column(type: "datetime2", nullable: false), - RelatedTo = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + RelatedToJson = table.Column(type: "nvarchar(max)", maxLength: 4000, nullable: true), Category = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), EventType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true) }, @@ -147,6 +147,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Status = table.Column(type: "int", nullable: false), ProcessingAttemptsJson = table.Column(type: "nvarchar(max)", nullable: false), FailureGroupsJson = table.Column(type: "nvarchar(max)", nullable: false), + HeadersJson = table.Column(type: "nvarchar(max)", nullable: false), PrimaryFailureGroupId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), MessageId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), MessageType = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), @@ -158,10 +159,7 @@ protected override void Up(MigrationBuilder migrationBuilder) QueueAddress = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), NumberOfProcessingAttempts = table.Column(type: "int", nullable: true), LastProcessedAt = table.Column(type: "datetime2", nullable: true), - ConversationId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), - CriticalTime = table.Column(type: "time", nullable: true), - ProcessingTime = table.Column(type: "time", nullable: true), - DeliveryTime = table.Column(type: "time", nullable: true) + ConversationId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true) }, constraints: table => { @@ -410,16 +408,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "FailedMessages", columns: new[] { "ConversationId", "LastProcessedAt" }); - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_CriticalTime", - table: "FailedMessages", - column: "CriticalTime"); - - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_DeliveryTime", - table: "FailedMessages", - column: "DeliveryTime"); - migrationBuilder.CreateIndex( name: "IX_FailedMessages_MessageId", table: "FailedMessages", @@ -435,11 +423,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "FailedMessages", columns: new[] { "PrimaryFailureGroupId", "Status", "LastProcessedAt" }); - migrationBuilder.CreateIndex( - name: "IX_FailedMessages_ProcessingTime", - table: "FailedMessages", - column: "ProcessingTime"); - migrationBuilder.CreateIndex( name: "IX_FailedMessages_QueueAddress_Status_LastProcessedAt", table: "FailedMessages", diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs index 2281aa3334..d108aef281 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -188,9 +188,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RaisedAt") .HasColumnType("datetime2"); - b.Property("RelatedTo") + b.Property("RelatedToJson") .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasColumnType("nvarchar(max)"); b.Property("Severity") .HasColumnType("int"); @@ -252,12 +252,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("nvarchar(200)"); - b.Property("CriticalTime") - .HasColumnType("time"); - - b.Property("DeliveryTime") - .HasColumnType("time"); - b.Property("ExceptionMessage") .HasColumnType("nvarchar(max)"); @@ -269,6 +263,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("LastProcessedAt") .HasColumnType("datetime2"); @@ -291,9 +289,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); - b.Property("ProcessingTime") - .HasColumnType("time"); - b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("nvarchar(500)"); @@ -319,14 +314,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("CriticalTime"); - - b.HasIndex("DeliveryTime"); - b.HasIndex("MessageId"); - b.HasIndex("ProcessingTime"); - b.HasIndex("UniqueMessageId") .IsUnique(); diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs index aa53ad4c67..66946e322b 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs @@ -11,6 +11,17 @@ public SqlServerDbContext(DbContextOptions options) : base(o protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) { - // SQL Server-specific configurations if needed + // SQL Server stores JSON as nvarchar(max), not jsonb (PostgreSQL-specific) + // Override all jsonb column types to use 'nvarchar(max)' + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.GetColumnType() == "jsonb") + { + property.SetColumnType("nvarchar(max)"); + } + } + } } }