From ac0360e9bfe07fc5f52b9f091d7f47bdf25da541 Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 8 Dec 2025 11:24:32 +1000 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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)"); + } + } + } } } From 3db0152565b532f5731704596a488850fe4e5b82 Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 16 Dec 2025 13:09:35 +1000 Subject: [PATCH 17/23] add plan for full text search --- docs/full-text-search-implementation-plan.md | 510 +++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 docs/full-text-search-implementation-plan.md diff --git a/docs/full-text-search-implementation-plan.md b/docs/full-text-search-implementation-plan.md new file mode 100644 index 0000000000..88e2564b73 --- /dev/null +++ b/docs/full-text-search-implementation-plan.md @@ -0,0 +1,510 @@ +# Full-Text Search Implementation Plan for FailedMessageEntity + +## Overview +Add comprehensive full-text search capability to `FailedMessageEntity` across all supported databases (PostgreSQL, MySQL, SQL Server), following the pattern established in the Audit persistence layer (PR #5106). + +## User Requirements (Confirmed) + +### Search Scope +✅ **Headers** - Search across all header values (from HeadersJson) +✅ **Message Body** - Include inline body content when below size threshold +❌ **Denormalized fields** - Skip (headers already contain this data) +❌ **ProcessingAttemptsJson** - Skip (not needed) + +### Inline Body Storage Pattern +Following the Audit implementation pattern from `PostgreSQLAuditIngestionUnitOfWork.cs`: +- Add `MaxBodySizeToStore` configuration setting +- Store message bodies inline in FailedMessageEntity when size ≤ threshold +- Only use separate MessageBodyEntity table for large bodies +- This enables body search without expensive JOINs + +### Implementation Approach +✅ **Computed Column Approach** (matching Audit implementation) +- Add tsvector/searchable text column +- Use database-specific triggers/functions for auto-update +- Native FTS capabilities per database + +--- + +## Current State Analysis + +### Existing Search Implementation +**File:** `src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs` + +Current search is very limited: +```csharp +query = query.Where(fm => + fm.MessageType!.Contains(searchTerms) || + fm.ExceptionMessage!.Contains(searchTerms) || + fm.UniqueMessageId.Contains(searchTerms)); +``` + +**Limitations:** +- Only searches 3 fields +- Uses LIKE pattern (slow on large datasets) +- No header or body search +- No ranking or relevance + +### FailedMessageEntity Structure +**File:** `src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs` + +**Denormalized fields available for search:** +- MessageId (string, 200 chars) +- MessageType (string, 500 chars) - currently searched +- ExceptionType (string, 500 chars) +- ExceptionMessage (string, 500 chars) - currently searched +- UniqueMessageId (string, 200 chars) - currently searched +- SendingEndpointName (string, 500 chars) +- ReceivingEndpointName (string, 500 chars) +- QueueAddress (string, 500 chars) +- ConversationId (string, 200 chars) + +**JSON columns:** +- HeadersJson (jsonb/json/nvarchar(max)) - recently added, contains all headers +- ProcessingAttemptsJson (jsonb/json/nvarchar(max)) - contains processing attempt metadata +- FailureGroupsJson (jsonb/json/nvarchar(max)) - contains failure group information + +**Related entity:** +- MessageBodyEntity (separate table) - contains message body content + +### Reference Implementation (Audit PR #5106) +The PostgreSQL Audit implementation provides a proven pattern: + +**Key components:** +1. **tsvector column** named `query` for full-text search index +2. **PostgreSQL trigger** to automatically update the tsvector on INSERT/UPDATE +3. **Weighted fields**: Headers (priority A), Body (priority B) +4. **Query using websearch_to_tsquery** with `@@` operator +5. **GIN index** on the tsvector column +6. **Autovacuum configuration** for maintenance + +--- + +## Implementation Plan + +### Phase 1: Schema Changes + +#### 1.1 Add Inline Body Storage and Search Columns to FailedMessageEntity +**File:** `src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs` + +Add two new columns: +```csharp +// Inline body storage (for small messages below threshold) +public byte[]? Body { get; set; } + +// Full-text search column (database-specific type) +public string? Query { get; set; } // Will be mapped to tsvector (PG), text (MySQL), nvarchar(max) (SQL Server) +``` + +**Rationale:** +- `Body` column stores message content inline when size ≤ MaxBodySizeToStore threshold +- Avoids JOIN with MessageBodyEntity for small messages (performance optimization) +- `Query` column stores the searchable text vector/index (matches Audit implementation naming) + +#### 1.2 Configure Column Types per Database +**Files:** +- `src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs` - Add basic configuration +- `src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs` - Override to tsvector +- `src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs` - Override to text +- `src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs` - Override to nvarchar(max) + +Configure column types: +- **Body**: `bytea` (PostgreSQL), `longblob` (MySQL), `varbinary(max)` (SQL Server) +- **Query**: `text` (PostgreSQL), `text` (MySQL), `nvarchar(max)` (SQL Server) + +**Note:** We use `text` for all databases instead of `tsvector` for PostgreSQL. The tsvector conversion happens at query time using `to_tsvector()`, keeping the storage consistent across databases. + +#### 1.3 Add Configuration Setting +**File:** `src/ServiceControl.Persistence.Sql.Core/SqlServerPersistenceConfiguration.cs` + +Add setting to control inline body storage threshold: +```csharp +public int MaxBodySizeToStore { get; set; } = 102400; // 100KB default (matches Audit) +``` + +### Phase 2: Database-Specific Setup + +#### 2.1 PostgreSQL Implementation +**New file:** `src/ServiceControl.Persistence.Sql.PostgreSQL/FullTextSearchSetup.cs` + +Create setup class to handle: +- `query` tsvector column configuration +- GIN index creation +- Trigger function creation for automatic tsvector updates +- Autovacuum configuration + +**PostgreSQL Index Setup** (simplified - no trigger): +```sql +-- GIN Index for fast full-text search on tsvector expression +CREATE INDEX idx_failed_messages_search +ON failed_messages +USING GIN(to_tsvector('english', COALESCE(query, ''))); + +-- Autovacuum configuration for high-throughput tables +ALTER TABLE failed_messages SET ( + autovacuum_vacuum_scale_factor = 0.05, + autovacuum_analyze_scale_factor = 0.02 +); +``` + +**Key Points:** +- **No trigger needed** - Query column populated from application code (consistent with other databases) +- **Expression index** - GIN index on `to_tsvector('english', query)` for full-text search +- Autovacuum keeps statistics current for high INSERT volume +- The `query` column type is `text` (consistent across databases) + +#### 2.2 MySQL Implementation +**New file:** `src/ServiceControl.Persistence.Sql.MySQL/FullTextSearchSetup.cs` + +Create setup class to handle: +- `query` text column for searchable text +- FULLTEXT index creation + +**MySQL Setup Pattern:** +```sql +-- FULLTEXT index on query column +CREATE FULLTEXT INDEX idx_failed_messages_search +ON failed_messages(query); +``` + +**Note:** MySQL doesn't support triggers that modify the same row, so the `query` column must be populated from application code during INSERT/UPDATE (see Phase 4). + +#### 2.3 SQL Server Implementation +**New file:** `src/ServiceControl.Persistence.Sql.SqlServer/FullTextSearchSetup.cs` + +Create setup class to handle: +- Full-text catalog creation +- Full-text index on `Query` column + +**SQL Server Setup Pattern:** +```sql +-- Create full-text catalog +IF NOT EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ft_failed_messages') + CREATE FULLTEXT CATALOG ft_failed_messages; + +-- Create full-text index on Query column +CREATE FULLTEXT INDEX ON FailedMessages(Query) +KEY INDEX PK_FailedMessages +ON ft_failed_messages +WITH CHANGE_TRACKING AUTO; +``` + +**Note:** SQL Server full-text indexing requires a primary key. The `Query` column must be populated from application code during INSERT/UPDATE (see Phase 4). + +### Phase 3: Migration Generation + +#### 3.1 Create New Migrations +Generate new migrations for each database provider that include: +- Add SearchableTextJson column +- Execute database-specific FTS setup scripts + +**Files to create:** +- PostgreSQL migration: Adds tsvector column, trigger, GIN index, autovacuum +- MySQL migration: Adds text column, FULLTEXT index +- SQL Server migration: Adds nvarchar(max) column, full-text catalog and index + +#### 3.2 Data Migration +For existing data, add migration step to populate the searchable text column: +- PostgreSQL: Trigger will handle automatically on next update, or force update +- MySQL: Application code updates during migration +- SQL Server: Application code updates during migration + +### Phase 4: Application Layer Changes + +#### 4.1 Update Inline Body Storage Logic in RecoverabilityIngestionUnitOfWork +**File:** `src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs` + +Modify `RecordFailedProcessingAttempt` to implement inline body storage pattern: + +```csharp +public async Task RecordFailedProcessingAttempt( + MessageContext context, + FailedMessage.ProcessingAttempt processingAttempt, + List groups) +{ + var uniqueMessageId = context.Headers.UniqueId(); + var contentType = GetContentType(context.Headers, MediaTypeNames.Text.Plain); + var bodySize = context.Body.Length; + + // Determine if body should be stored inline based on size threshold + byte[]? inlineBody = null; + bool storeBodySeparately = bodySize > parent.Configuration.MaxBodySizeToStore; + + if (!storeBodySeparately && !context.Body.IsEmpty) + { + inlineBody = context.Body.ToArray(); // Store inline + } + + // ... existing metadata and denormalization logic ... + + if (existingMessage != null) + { + // Update existing message + existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts); + existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups); + existingMessage.HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers); + existingMessage.Body = inlineBody; // Update inline body + existingMessage.Query = BuildSearchableText(processingAttempt.Headers, inlineBody); // Populate Query for all databases + // ... other updates ... + } + else + { + // Create new message + var failedMessageEntity = new FailedMessageEntity + { + // ... existing fields ... + HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers), + Body = inlineBody, // Store inline body + Query = BuildSearchableText(processingAttempt.Headers, inlineBody) // Populate Query for all databases + }; + parent.DbContext.FailedMessages.Add(failedMessageEntity); + } + + // Store body separately only if it exceeds threshold + if (storeBodySeparately) + { + await StoreMessageBody(uniqueMessageId, context.Body, contentType, bodySize); + } +} + +// Helper method to build searchable text (for MySQL/SQL Server) +private string BuildSearchableText(Dictionary headers, byte[]? body) +{ + var parts = new List + { + string.Join(" ", headers.Values) // All header values + }; + + // Add body content if present and can be decoded as text + if (body != null && body.Length > 0) + { + try + { + var bodyText = Encoding.UTF8.GetString(body); + parts.Add(bodyText); + } + catch + { + // Skip non-text bodies + } + } + + return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); +} +``` + +**Key Changes:** +- Check body size against `MaxBodySizeToStore` threshold +- Store small bodies inline in `Body` column +- Store large bodies in separate `MessageBodyEntity` table +- **Populate `Query` column for all databases** (consistent application-level approach) +- Build searchable text from headers + inline body +- Query column stores plain text for all databases (PostgreSQL converts to tsvector at query time) + +#### 4.2 Create Full-Text Search Provider Interface +**New file:** `src/ServiceControl.Persistence.Sql.Core/FullTextSearch/IFullTextSearchProvider.cs` + +```csharp +public interface IFullTextSearchProvider +{ + IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms); +} +``` + +#### 4.3 Implement Database-Specific Providers + +**New file:** `src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlFullTextSearchProvider.cs` +```csharp +public class PostgreSqlFullTextSearchProvider : IFullTextSearchProvider +{ + public IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms) + { + // Convert text to tsvector at query time, use websearch_to_tsquery for user-friendly search + return query.FromSqlRaw( + @"SELECT * FROM failed_messages + WHERE to_tsvector('english', COALESCE(query, '')) @@ websearch_to_tsquery('english', {0})", + searchTerms); + } +} +``` + +**New file:** `src/ServiceControl.Persistence.Sql.MySQL/MySqlFullTextSearchProvider.cs` +```csharp +public class MySqlFullTextSearchProvider : IFullTextSearchProvider +{ + public IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms) + { + // Use NATURAL LANGUAGE MODE for user-friendly search + return query.FromSqlRaw( + @"SELECT * FROM failed_messages + WHERE MATCH(query) AGAINST({0} IN NATURAL LANGUAGE MODE)", + searchTerms); + } +} +``` + +**New file:** `src/ServiceControl.Persistence.Sql.SqlServer/SqlServerFullTextSearchProvider.cs` +```csharp +public class SqlServerFullTextSearchProvider : IFullTextSearchProvider +{ + public IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms) + { + // Use CONTAINS for boolean full-text search + return query.FromSqlRaw( + @"SELECT * FROM FailedMessages + WHERE CONTAINS(Query, {0})", + searchTerms); + } +} +``` + +#### 4.4 Update Query Methods +**File:** `src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs` + +Replace the current simple `Contains()` search with full-text search provider: + +```csharp +public Task>> GetAllMessagesForSearch( + string searchTerms, + PagingInfo pagingInfo, + SortInfo sortInfo, + DateTimeRange? timeSentRange = null) +{ + return ExecuteWithDbContext(async dbContext => + { + var query = dbContext.FailedMessages.AsQueryable(); + + // Apply full-text search + if (!string.IsNullOrWhiteSpace(searchTerms)) + { + query = _fullTextSearchProvider.ApplyFullTextSearch(query, searchTerms); + } + + // Apply time range filter + // ... existing code ... + }); +} +``` + +#### 4.5 Register Providers in DI +**Files:** +- `src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs` +- `src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs` +- `src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs` + +Register the appropriate provider for each database: +```csharp +services.AddSingleton(); +``` + +### Phase 5: Configuration + +#### 5.1 Add Settings +**File:** `src/ServiceControl.Persistence.Sql.Core/SqlServerPersistenceConfiguration.cs` (or equivalent) + +Add configuration option: +```csharp +public bool EnableFullTextSearchOnBodies { get; set; } = true; +``` + +### Phase 6: Testing + +#### 6.1 Unit Tests +Create unit tests for: +- Searchable text building logic +- Each database provider's query generation + +#### 6.2 Integration Tests +Add integration tests to verify: +- Full-text search across all three databases +- Search ranking/relevance +- Performance with large datasets +- Migration success + +--- + +## Critical Files to Modify + +### Core Layer (Database-Agnostic) +1. `src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs` - Add SearchableTextJson column +2. `src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs` - Configure column +3. `src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs` - Update search methods +4. `src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs` - Populate searchable text +5. `src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs` - Populate searchable text +6. `src/ServiceControl.Persistence.Sql.Core/FullTextSearch/IFullTextSearchProvider.cs` - New interface + +### PostgreSQL-Specific +7. `src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs` - Override column type configuration +8. `src/ServiceControl.Persistence.Sql.PostgreSQL/FullTextSearchSetup.cs` - New: FTS setup scripts +9. `src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlFullTextSearchProvider.cs` - New: Query provider +10. `src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs` - Register provider +11. `src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/` - New migration + +### MySQL-Specific +12. `src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs` - Override column type configuration +13. `src/ServiceControl.Persistence.Sql.MySQL/FullTextSearchSetup.cs` - New: FTS setup scripts +14. `src/ServiceControl.Persistence.Sql.MySQL/MySqlFullTextSearchProvider.cs` - New: Query provider +15. `src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs` - Register provider +16. `src/ServiceControl.Persistence.Sql.MySQL/Migrations/` - New migration + +### SQL Server-Specific +17. `src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs` - Override column type configuration +18. `src/ServiceControl.Persistence.Sql.SqlServer/FullTextSearchSetup.cs` - New: FTS setup scripts +19. `src/ServiceControl.Persistence.Sql.SqlServer/SqlServerFullTextSearchProvider.cs` - New: Query provider +20. `src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs` - Register provider +21. `src/ServiceControl.Persistence.Sql.SqlServer/Migrations/` - New migration + +--- + +## Trade-offs and Considerations + +### Computed Column Approach (Recommended) +**Pros:** +- Matches proven Audit implementation pattern +- Better query performance (pre-computed search text) +- Cleaner query code +- Native database FTS capabilities + +**Cons:** +- More complex migrations +- Additional storage for search column +- Database-specific trigger/computed column setup +- Requires careful testing per database + +### Alternative: Query-Time Composition +**Pros:** +- Simpler schema +- No additional storage +- Easier to understand + +**Cons:** +- Slower queries (on-the-fly text assembly) +- More complex application code +- Harder to leverage native FTS features +- Limited ranking/relevance capabilities + +--- + +## Summary + +This plan implements comprehensive full-text search for `FailedMessageEntity` across PostgreSQL, MySQL, and SQL Server by: + +1. **Adding inline body storage** - Following the Audit pattern, small message bodies (≤100KB) are stored inline to avoid JOINs +2. **Using native FTS per database** - PostgreSQL (tsvector/GIN), MySQL (FULLTEXT), SQL Server (full-text catalog) +3. **Prioritizing search fields** - Headers (weight A/high priority) + Body (weight B/medium priority) +4. **Leveraging triggers** - PostgreSQL auto-updates search vectors; MySQL/SQL Server use application code +5. **Maintaining backward compatibility** - Replaces simple LIKE search with proper full-text search + +**Key Benefits:** +- **Performance**: Native FTS indexes provide O(log N) search instead of O(N) table scans +- **Relevance**: Weighted fields (headers > body) improve search result quality +- **Scalability**: Inline body storage reduces JOINs for 90%+ of messages +- **Consistency**: Matches proven Audit implementation pattern From 0b41b7a50527a6f8482c33849f359742e3f6407f Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 16 Dec 2025 16:39:00 +1000 Subject: [PATCH 18/23] Small fixes --- src/ProjectReferences.Persisters.Primary.props | 6 +++--- .../Abstractions/BasePersistence.cs | 8 +------- .../Implementation/ErrorMessageDataStore.cs | 5 +++-- .../Implementation/NotificationsManager.cs | 5 ++--- .../MySqlPersistence.cs | 4 ++-- .../PostgreSqlPersistence.cs | 3 ++- .../SqlServerPersistence.cs | 3 ++- .../DevelopmentPersistenceLocations.cs | 3 +++ 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/ProjectReferences.Persisters.Primary.props b/src/ProjectReferences.Persisters.Primary.props index c0dfc95209..7729dc6b4f 100644 --- a/src/ProjectReferences.Persisters.Primary.props +++ b/src/ProjectReferences.Persisters.Primary.props @@ -2,9 +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 3329c92b36..cc3cba6cd6 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs @@ -10,13 +10,8 @@ namespace ServiceControl.Persistence.Sql.Core.Abstractions; public abstract class BasePersistence { - protected static void RegisterDataStores(IServiceCollection services, bool maintenanceMode) + protected static void RegisterDataStores(IServiceCollection services) { - if (maintenanceMode) - { - return; - } - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -35,7 +30,6 @@ protected static void RegisterDataStores(IServiceCollection services, bool maint services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs index 2c0544db61..3497964c15 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -65,8 +65,9 @@ public Task CreateEditFailedMessageManager() public Task CreateNotificationsManager() { - var notificationsManager = serviceProvider.GetRequiredService(); - return Task.FromResult(notificationsManager); + var scope = serviceProvider.CreateScope(); + var manager = new NotificationsManager(scope); + return Task.FromResult(manager); } public async Task StoreEventLogItem(EventLogItem logItem) diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs index 73a2a56afe..ac26875230 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs @@ -4,7 +4,6 @@ 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; @@ -30,7 +29,7 @@ public async Task LoadSettings(TimeSpan? cacheTimeout = n }; } - var emailSettings = JsonSerializer.Deserialize(entity.EmailSettingsJson, JsonSerializationOptions.Default) ?? new EmailNotifications(); + var emailSettings = JsonSerializer.Deserialize(entity.EmailSettingsJson, JsonSerializerOptions.Default) ?? new EmailNotifications(); return new NotificationsSettings { @@ -43,7 +42,7 @@ public async Task GetUnresolvedCount() { return await dbContext.FailedMessages .AsNoTracking() - .Where(m => m.Status == ServiceControl.MessageFailures.FailedMessageStatus.Unresolved) + .Where(m => m.Status == MessageFailures.FailedMessageStatus.Unresolved) .CountAsync(); } diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs index e34f650f09..4a1ec37633 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs @@ -18,13 +18,13 @@ public MySqlPersistence(MySqlPersisterSettings settings) public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); - RegisterDataStores(services, settings.MaintenanceMode); + RegisterDataStores(services); } public void AddInstaller(IServiceCollection services) { ConfigureDbContext(services); - + RegisterDataStores(services); services.AddSingleton(); } diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs index 4ee76b9981..eef0a1a86b 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -18,12 +18,13 @@ public PostgreSqlPersistence(PostgreSqlPersisterSettings settings) public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); - RegisterDataStores(services, settings.MaintenanceMode); + RegisterDataStores(services); } public void AddInstaller(IServiceCollection services) { ConfigureDbContext(services); + RegisterDataStores(services); services.AddSingleton(); } diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs index d9665cb898..595c81bec0 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs @@ -18,12 +18,13 @@ public SqlServerPersistence(SqlServerPersisterSettings settings) public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); - RegisterDataStores(services, settings.MaintenanceMode); + RegisterDataStores(services); } public void AddInstaller(IServiceCollection services) { ConfigureDbContext(services); + RegisterDataStores(services); // Register the database migrator - this runs during installation/setup services.AddSingleton(); diff --git a/src/ServiceControl.Persistence/DevelopmentPersistenceLocations.cs b/src/ServiceControl.Persistence/DevelopmentPersistenceLocations.cs index 14f31e9f1b..cbb6b3848f 100644 --- a/src/ServiceControl.Persistence/DevelopmentPersistenceLocations.cs +++ b/src/ServiceControl.Persistence/DevelopmentPersistenceLocations.cs @@ -18,6 +18,9 @@ static DevelopmentPersistenceLocations() if (!string.IsNullOrWhiteSpace(srcFolder) && srcFolder.EndsWith("src")) { ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Persistence.RavenDB")); + ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Persistence.Sql.SqlServer")); + ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Persistence.Sql.PostgreSQL")); + ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Persistence.Sql.MySQL")); } } From bfa6ad149d8d3fc148f7e82fd730bf1a8f40cac6 Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 16 Dec 2025 18:37:31 +1000 Subject: [PATCH 19/23] Refactors data stores to use IServiceScopeFactory Updates data stores to utilize IServiceScopeFactory instead of IServiceProvider for creating database scopes. This change improves dependency injection and resource management, ensuring proper scope lifecycle management, especially for asynchronous operations. --- .../Implementation/ArchiveMessages.cs | 6 +++--- .../Implementation/BodyStorage.cs | 3 ++- .../Implementation/CustomChecksDataStore.cs | 3 ++- .../Implementation/DataStoreBase.cs | 12 ++++++------ .../Implementation/EndpointSettingsStore.cs | 4 ++-- .../Implementation/ErrorMessageDataStore.cs | 8 ++++---- .../Implementation/EventLogDataStore.cs | 3 ++- .../ExternalIntegrationRequestsDataStore.cs | 5 +++-- .../Implementation/FailedErrorImportDataStore.cs | 3 ++- .../Implementation/GroupsDataStore.cs | 3 ++- .../Implementation/LicensingDataStore.cs | 3 ++- .../Implementation/MessageRedirectsDataStore.cs | 3 ++- .../Implementation/MonitoringDataStore.cs | 3 ++- .../Implementation/NotificationsManager.cs | 13 +++---------- .../Implementation/QueueAddressStore.cs | 3 ++- .../Implementation/RetryBatchesDataStore.cs | 5 +++-- .../Implementation/RetryDocumentDataStore.cs | 5 +++-- .../Implementation/RetryHistoryDataStore.cs | 3 ++- .../ServiceControlSubscriptionStorage.cs | 9 +++++---- .../Implementation/TrialLicenseDataProvider.cs | 3 ++- .../IDatabaseMigrator.cs | 5 ++++- src/ServiceControl/Hosting/Commands/SetupCommand.cs | 4 ++++ 22 files changed, 62 insertions(+), 47 deletions(-) rename src/{ServiceControl.Persistence.Sql.Core/Abstractions => ServiceControl.Persistence}/IDatabaseMigrator.cs (54%) diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs index 3db1a5d183..55aa377e87 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ArchiveMessages.cs @@ -19,9 +19,9 @@ public class ArchiveMessages : DataStoreBase, IArchiveMessages readonly ILogger logger; public ArchiveMessages( - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, IDomainEvents domainEvents, - ILogger logger) : base(serviceProvider) + ILogger logger) : base(scopeFactory) { this.domainEvents = domainEvents; this.logger = logger; @@ -129,7 +129,7 @@ public Task StartUnarchiving(string groupId, ArchiveType archiveType) public IEnumerable GetArchivalOperations() { // Note: IEnumerable methods need direct scope management as they yield results - using var scope = serviceProvider.CreateScope(); + using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var operations = dbContext.ArchiveOperations diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs index e4151988e9..291b2aea00 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs @@ -4,11 +4,12 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.IO; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.Operations.BodyStorage; public class BodyStorage : DataStoreBase, IBodyStorage { - public BodyStorage(IServiceProvider serviceProvider) : base(serviceProvider) + public BodyStorage(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs index ff02d9f381..937ad4b251 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/CustomChecksDataStore.cs @@ -7,13 +7,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Contracts.CustomChecks; using Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Operations; using ServiceControl.Persistence; using ServiceControl.Persistence.Infrastructure; public class CustomChecksDataStore : DataStoreBase, ICustomChecksDataStore { - public CustomChecksDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public CustomChecksDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs index 38ea61b2b9..d485281a3a 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/DataStoreBase.cs @@ -10,11 +10,11 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; /// public abstract class DataStoreBase { - protected readonly IServiceProvider serviceProvider; + protected readonly IServiceScopeFactory scopeFactory; - protected DataStoreBase(IServiceProvider serviceProvider) + protected DataStoreBase(IServiceScopeFactory scopeFactory) { - this.serviceProvider = serviceProvider; + this.scopeFactory = scopeFactory; } /// @@ -22,7 +22,7 @@ protected DataStoreBase(IServiceProvider serviceProvider) /// protected async Task ExecuteWithDbContext(Func> operation) { - using var scope = serviceProvider.CreateScope(); + await using var scope = scopeFactory.CreateAsyncScope();// Use CreateAsyncScope for async disposal var dbContext = scope.ServiceProvider.GetRequiredService(); return await operation(dbContext); } @@ -32,7 +32,7 @@ protected async Task ExecuteWithDbContext(Func protected async Task ExecuteWithDbContext(Func operation) { - using var scope = serviceProvider.CreateScope(); + await using var scope = scopeFactory.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); await operation(dbContext); } @@ -40,5 +40,5 @@ protected async Task ExecuteWithDbContext(Func /// Creates a scope for operations that need to manage their own scope lifecycle (e.g., managers) /// - protected IServiceScope CreateScope() => serviceProvider.CreateScope(); + protected IServiceScope CreateScope() => scopeFactory.CreateAsyncScope(); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs index 4f9ef498be..b2e1e0ef73 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs @@ -11,14 +11,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; public class EndpointSettingsStore : DataStoreBase, IEndpointSettingsStore { - public EndpointSettingsStore(IServiceProvider serviceProvider) : base(serviceProvider) + public EndpointSettingsStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } public async IAsyncEnumerable GetAllEndpointSettings() { // Note: IAsyncEnumerable methods need direct scope management as they yield results - using var scope = serviceProvider.CreateScope(); + await using var scope = scopeFactory.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var entities = dbContext.EndpointSettings.AsNoTracking().AsAsyncEnumerable(); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs index 3497964c15..bd48113971 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -16,7 +16,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; partial class ErrorMessageDataStore : DataStoreBase, IErrorMessageDataStore { - public ErrorMessageDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public ErrorMessageDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } @@ -58,21 +58,21 @@ public Task StoreFailedErrorImport(FailedErrorImport failure) public Task CreateEditFailedMessageManager() { - var scope = serviceProvider.CreateScope(); + var scope = scopeFactory.CreateScope(); var manager = new EditFailedMessagesManager(scope); return Task.FromResult(manager); } public Task CreateNotificationsManager() { - var scope = serviceProvider.CreateScope(); + var scope = scopeFactory.CreateScope(); var manager = new NotificationsManager(scope); return Task.FromResult(manager); } public async Task StoreEventLogItem(EventLogItem logItem) { - using var scope = serviceProvider.CreateScope(); + await using var scope = scopeFactory.CreateAsyncScope(); var eventLogDataStore = scope.ServiceProvider.GetRequiredService(); await eventLogDataStore.Add(logItem); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs index 49dc6448f3..547743c2dc 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EventLogDataStore.cs @@ -8,13 +8,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.EventLog; using ServiceControl.Persistence; using ServiceControl.Persistence.Infrastructure; public class EventLogDataStore : DataStoreBase, IEventLogDataStore { - public EventLogDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public EventLogDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs index 7a243cc929..90257b4daf 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs @@ -9,6 +9,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ServiceControl.ExternalIntegrations; using ServiceControl.Persistence; @@ -23,8 +24,8 @@ public class ExternalIntegrationRequestsDataStore : DataStoreBase, IExternalInte bool isDisposed; public ExternalIntegrationRequestsDataStore( - IServiceProvider serviceProvider, - ILogger logger) : base(serviceProvider) + IServiceScopeFactory scopeFactory, + ILogger logger) : base(scopeFactory) { this.logger = logger; } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs index 9332b8e8df..98bac472cd 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs @@ -10,12 +10,13 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Microsoft.Extensions.Logging; using ServiceControl.Operations; using ServiceControl.Persistence; +using Microsoft.Extensions.DependencyInjection; public class FailedErrorImportDataStore : DataStoreBase, IFailedErrorImportDataStore { readonly ILogger logger; - public FailedErrorImportDataStore(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider) + public FailedErrorImportDataStore(IServiceScopeFactory scopeFactory, ILogger logger) : base(scopeFactory) { this.logger = logger; } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs index fe41256002..37f2c00127 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs @@ -8,13 +8,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.MessageFailures; using ServiceControl.Persistence; using ServiceControl.Recoverability; public class GroupsDataStore : DataStoreBase, IGroupsDataStore { - public GroupsDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public GroupsDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs index 1551732d85..5776cdd766 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs @@ -6,13 +6,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Threading.Tasks; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; 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) + public LicensingDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } #region Throughput diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs index 36ec2dd8c9..8f1b545a15 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs @@ -6,11 +6,12 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence.MessageRedirects; public class MessageRedirectsDataStore : DataStoreBase, IMessageRedirectsDataStore { - public MessageRedirectsDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public MessageRedirectsDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs index 2f448fb364..8a6e2a57ee 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MonitoringDataStore.cs @@ -6,12 +6,13 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Threading.Tasks; using Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.Operations; using ServiceControl.Persistence; public class MonitoringDataStore : DataStoreBase, IMonitoringDataStore { - public MonitoringDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public MonitoringDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs index ac26875230..a609ba4848 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs @@ -8,6 +8,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Notifications; using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; class NotificationsManager(IServiceScope scope) : INotificationsManager { @@ -17,7 +18,7 @@ public async Task LoadSettings(TimeSpan? cacheTimeout = n { var entity = await dbContext.NotificationsSettings .AsNoTracking() - .FirstOrDefaultAsync(s => s.Id == Guid.Parse(NotificationsSettings.SingleDocumentId)); + .FirstOrDefaultAsync(s => s.Id == DeterministicGuid.MakeId(NotificationsSettings.SingleDocumentId)); if (entity == null) { @@ -33,19 +34,11 @@ public async Task LoadSettings(TimeSpan? cacheTimeout = n return new NotificationsSettings { - Id = entity.Id.ToString(), + Id = NotificationsSettings.SingleDocumentId, Email = emailSettings }; } - public async Task GetUnresolvedCount() - { - return await dbContext.FailedMessages - .AsNoTracking() - .Where(m => m.Status == MessageFailures.FailedMessageStatus.Unresolved) - .CountAsync(); - } - public async Task SaveChanges() { await dbContext.SaveChangesAsync(); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs index f3c66766ba..8e3ed99861 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/QueueAddressStore.cs @@ -4,13 +4,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.MessageFailures; using ServiceControl.Persistence; using ServiceControl.Persistence.Infrastructure; public class QueueAddressStore : DataStoreBase, IQueueAddressStore { - public QueueAddressStore(IServiceProvider serviceProvider) : base(serviceProvider) + public QueueAddressStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs index a806a63100..1d1016c926 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesDataStore.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ServiceControl.MessageFailures; using ServiceControl.Persistence; @@ -15,8 +16,8 @@ public class RetryBatchesDataStore : DataStoreBase, IRetryBatchesDataStore readonly ILogger logger; public RetryBatchesDataStore( - IServiceProvider serviceProvider, - ILogger logger) : base(serviceProvider) + IServiceScopeFactory scopeFactory, + ILogger logger) : base(scopeFactory) { this.logger = logger; } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs index 646b55cfc0..10dc0f1ce9 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs @@ -8,6 +8,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ServiceControl.MessageFailures; using ServiceControl.Persistence; @@ -19,8 +20,8 @@ public class RetryDocumentDataStore : DataStoreBase, IRetryDocumentDataStore readonly ILogger logger; public RetryDocumentDataStore( - IServiceProvider serviceProvider, - ILogger logger) : base(serviceProvider) + IServiceScopeFactory scopeFactory, + ILogger logger) : base(scopeFactory) { this.logger = logger; } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs index 05171b740e..baa74db14a 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs @@ -8,6 +8,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; using ServiceControl.Recoverability; @@ -15,7 +16,7 @@ public class RetryHistoryDataStore : DataStoreBase, IRetryHistoryDataStore { const int SingletonId = 1; - public RetryHistoryDataStore(IServiceProvider serviceProvider) : base(serviceProvider) + public RetryHistoryDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs index 732b3419b5..a7fc7348ae 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs @@ -11,6 +11,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Infrastructure; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using NServiceBus.Extensibility; using NServiceBus.Settings; using NServiceBus.Unicast.Subscriptions; @@ -26,11 +27,11 @@ public class ServiceControlSubscriptionStorage : DataStoreBase, IServiceControlS readonly SemaphoreSlim subscriptionsLock = new SemaphoreSlim(1); public ServiceControlSubscriptionStorage( - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, IReadOnlySettings settings, ReceiveAddresses receiveAddresses) : this( - serviceProvider, + scopeFactory, settings.EndpointName(), receiveAddresses.MainReceiveAddress, settings.GetAvailableTypes().Implementing().Select(e => new MessageType(e)).ToArray()) @@ -38,10 +39,10 @@ public ServiceControlSubscriptionStorage( } public ServiceControlSubscriptionStorage( - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, string endpointName, string localAddress, - MessageType[] locallyHandledEventTypes) : base(serviceProvider) + MessageType[] locallyHandledEventTypes) : base(scopeFactory) { localClient = new SubscriptionClient { diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs index 2157a991fc..9450e40ccc 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs @@ -2,13 +2,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; using Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; public class TrialLicenseDataProvider : DataStoreBase, ITrialLicenseDataProvider { const int SingletonId = 1; - public TrialLicenseDataProvider(IServiceProvider serviceProvider) : base(serviceProvider) + public TrialLicenseDataProvider(IServiceScopeFactory scopeFactory) : base(scopeFactory) { } diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs b/src/ServiceControl.Persistence/IDatabaseMigrator.cs similarity index 54% rename from src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs rename to src/ServiceControl.Persistence/IDatabaseMigrator.cs index 39de35cfe4..d5836fe503 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs +++ b/src/ServiceControl.Persistence/IDatabaseMigrator.cs @@ -1,4 +1,7 @@ -namespace ServiceControl.Persistence.Sql.Core.Abstractions; +namespace ServiceControl.Persistence; + +using System.Threading; +using System.Threading.Tasks; public interface IDatabaseMigrator { diff --git a/src/ServiceControl/Hosting/Commands/SetupCommand.cs b/src/ServiceControl/Hosting/Commands/SetupCommand.cs index 3fdc2e7e11..12bd81c2be 100644 --- a/src/ServiceControl/Hosting/Commands/SetupCommand.cs +++ b/src/ServiceControl/Hosting/Commands/SetupCommand.cs @@ -2,6 +2,7 @@ { using System.Runtime.InteropServices; using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Particular.ServiceControl; @@ -9,6 +10,7 @@ using ServiceBus.Management.Infrastructure.Installers; using ServiceBus.Management.Infrastructure.Settings; using ServiceControl.Infrastructure; + using ServiceControl.Persistence; using Transports; class SetupCommand : AbstractCommand @@ -45,6 +47,8 @@ public override async Task Execute(HostArguments args, Settings settings) var transportCustomization = TransportFactory.Create(transportSettings); await transportCustomization.ProvisionQueues(transportSettings, componentSetupContext.Queues); + + await host.Services.GetRequiredService().ApplyMigrations(); } await host.StopAsync(); From 34d6debe141d90f6ec0e432f7c6154d420bf0510 Mon Sep 17 00:00:00 2001 From: John Simons Date: Wed, 17 Dec 2025 17:21:55 +1000 Subject: [PATCH 20/23] Improves error message search capabilities Adds full-text search capabilities for error messages, allowing users to search within message headers and, optionally, the message body. Introduces an interface for full-text search providers to abstract the database-specific implementation. Stores small message bodies inline for faster retrieval and populates a searchable text field from headers and the message body. Adds configuration option to set the maximum body size to store inline. --- .../DbContexts/ServiceControlDbContextBase.cs | 7 + .../Entities/FailedMessageEntity.cs | 6 + .../FailedMessageConfiguration.cs | 4 + .../FullTextSearch/IFullTextSearchProvider.cs | 11 + .../ErrorMessageDataStore.MessageQueries.cs | 14 +- .../Implementation/ErrorMessageDataStore.cs | 8 +- .../UnitOfWork/IngestionUnitOfWork.cs | 5 +- .../UnitOfWork/IngestionUnitOfWorkFactory.cs | 3 +- .../RecoverabilityIngestionUnitOfWork.cs | 45 +- .../MySqlFullTextSearchProvider.cs | 16 + .../MySqlPersistence.cs | 3 + .../20251216015817_InitialCreate.Designer.cs | 850 ------------------ .../20251216015817_InitialCreate.cs | 572 ------------ .../PostgreSqlDbContextModelSnapshot.cs | 847 ----------------- .../PostgreSqlFullTextSearchProvider.cs | 18 + .../PostgreSqlPersistence.cs | 6 + .../SqlServerFullTextSearchProvider.cs | 18 + .../SqlServerPersistence.cs | 3 + .../PersistenceSettings.cs | 2 + 19 files changed, 154 insertions(+), 2284 deletions(-) create mode 100644 src/ServiceControl.Persistence.Sql.Core/FullTextSearch/IFullTextSearchProvider.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/MySqlFullTextSearchProvider.cs delete mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs delete mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs delete mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlFullTextSearchProvider.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/SqlServerFullTextSearchProvider.cs diff --git a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs index c37f8a9976..f6578ab12d 100644 --- a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.Sql.Core.DbContexts; using Entities; using EntityConfigurations; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; public abstract class ServiceControlDbContextBase : DbContext { @@ -33,6 +34,12 @@ protected ServiceControlDbContextBase(DbContextOptions options) : base(options) public DbSet Endpoints { get; set; } public DbSet Throughput { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.LogTo(Console.WriteLine, LogLevel.Warning) + .EnableDetailedErrors(); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs index 4e161dad1c..13caee5741 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs @@ -14,6 +14,12 @@ public class FailedMessageEntity public string FailureGroupsJson { get; set; } = null!; public string HeadersJson { get; set; } = null!; + // Inline body storage for small messages (below MaxBodySizeToStore threshold) + public byte[]? Body { get; set; } + + // Full-text search column (populated from headers + inline body) + public string? Query { get; set; } + // Denormalized fields from FailureGroups for efficient filtering // PrimaryFailureGroupId is the first group ID from FailureGroupsJson array public string? PrimaryFailureGroupId { get; set; } diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs index f0a6ef63fe..afcd4f30f2 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs @@ -17,6 +17,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.FailureGroupsJson).HasColumnType("jsonb").IsRequired(); builder.Property(e => e.HeadersJson).HasColumnType("jsonb").IsRequired(); + // Full-text search and inline body storage + builder.Property(e => e.Body); // Will be mapped to bytea/longblob/varbinary(max) per database + builder.Property(e => e.Query); // Will be mapped to text/nvarchar(max) per database + // Denormalized query fields from FailureGroups builder.Property(e => e.PrimaryFailureGroupId).HasMaxLength(200); diff --git a/src/ServiceControl.Persistence.Sql.Core/FullTextSearch/IFullTextSearchProvider.cs b/src/ServiceControl.Persistence.Sql.Core/FullTextSearch/IFullTextSearchProvider.cs new file mode 100644 index 0000000000..7368413577 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/FullTextSearch/IFullTextSearchProvider.cs @@ -0,0 +1,11 @@ +namespace ServiceControl.Persistence.Sql.Core.FullTextSearch; + +using System.Linq; +using Entities; + +public interface IFullTextSearchProvider +{ + IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms); +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs index 620cb49a2f..063adb7899 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs @@ -113,13 +113,10 @@ public Task>> GetAllMessagesForSearch(string sea { var query = dbContext.FailedMessages.AsQueryable(); - // Apply search filter + // Apply full-text search if (!string.IsNullOrWhiteSpace(searchTerms)) { - query = query.Where(fm => - fm.MessageType!.Contains(searchTerms) || - fm.ExceptionMessage!.Contains(searchTerms) || - fm.UniqueMessageId.Contains(searchTerms)); + query = fullTextSearchProvider.ApplyFullTextSearch(query, searchTerms); } // Apply time range filter @@ -158,13 +155,10 @@ public Task>> SearchEndpointMessages(string endp var query = dbContext.FailedMessages .Where(fm => fm.ReceivingEndpointName == endpointName); - // Apply search filter + // Apply full-text search if (!string.IsNullOrWhiteSpace(searchKeyword)) { - query = query.Where(fm => - fm.MessageType!.Contains(searchKeyword) || - fm.ExceptionMessage!.Contains(searchKeyword) || - fm.UniqueMessageId.Contains(searchKeyword)); + query = fullTextSearchProvider.ApplyFullTextSearch(query, searchKeyword); } // Apply time range filter diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs index bd48113971..208fade35d 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 FullTextSearch; using Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -16,8 +17,11 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; partial class ErrorMessageDataStore : DataStoreBase, IErrorMessageDataStore { - public ErrorMessageDataStore(IServiceScopeFactory scopeFactory) : base(scopeFactory) + readonly IFullTextSearchProvider fullTextSearchProvider; + + public ErrorMessageDataStore(IServiceScopeFactory scopeFactory, IFullTextSearchProvider fullTextSearchProvider) : base(scopeFactory) { + this.fullTextSearchProvider = fullTextSearchProvider; } public Task FailedMessagesFetch(Guid[] ids) @@ -93,6 +97,8 @@ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessage ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts, JsonSerializationOptions.Default), FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups, JsonSerializationOptions.Default), HeadersJson = JsonSerializer.Serialize(lastAttempt?.Headers ?? [], JsonSerializationOptions.Default), + Body = null, // Test data doesn't include inline bodies + Query = null, // Test data doesn't populate search text 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/UnitOfWork/IngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs index 806c4a1d07..210a68d11f 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs @@ -3,18 +3,21 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; using System.Threading; using System.Threading.Tasks; using DbContexts; +using ServiceControl.Persistence; using ServiceControl.Persistence.UnitOfWork; class IngestionUnitOfWork : IngestionUnitOfWorkBase { - public IngestionUnitOfWork(ServiceControlDbContextBase dbContext) + public IngestionUnitOfWork(ServiceControlDbContextBase dbContext, PersistenceSettings settings) { DbContext = dbContext; + Settings = settings; Monitoring = new MonitoringIngestionUnitOfWork(this); Recoverability = new RecoverabilityIngestionUnitOfWork(this); } internal ServiceControlDbContextBase DbContext { get; } + internal PersistenceSettings Settings { get; } // EF Core automatically batches all pending operations // The upsert operations execute SQL directly, but EF Core tracked changes (Add/Remove/Update) are batched diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs index f630175b2e..64aff3bf0b 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs @@ -13,7 +13,8 @@ public ValueTask StartNew() { var scope = serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var unitOfWork = new IngestionUnitOfWork(dbContext); + var settings = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = new IngestionUnitOfWork(dbContext, settings); return ValueTask.FromResult(unitOfWork); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs index 0b3c730fc9..f696d13759 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; using System.Collections.Generic; using System.Linq; using System.Net.Mime; +using System.Text; using System.Text.Json; using System.Threading.Tasks; using Entities; @@ -38,6 +39,15 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe var contentType = GetContentType(context.Headers, MediaTypeNames.Text.Plain); var bodySize = context.Body.Length; + // Determine if body should be stored inline based on size threshold + byte[]? inlineBody = null; + bool storeBodySeparately = bodySize > parent.Settings.MaxBodySizeToStore; + + if (!storeBodySeparately && !context.Body.IsEmpty) + { + inlineBody = context.Body.ToArray(); // Store inline + } + // Add metadata to the processing attempt processingAttempt.MessageMetadata.Add("ContentType", contentType); processingAttempt.MessageMetadata.Add("ContentLength", bodySize); @@ -79,6 +89,8 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts, JsonSerializationOptions.Default); existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups, JsonSerializationOptions.Default); existingMessage.HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers, JsonSerializationOptions.Default); + existingMessage.Body = inlineBody; // Update inline body + existingMessage.Query = BuildSearchableText(processingAttempt.Headers, inlineBody); // Populate Query for all databases existingMessage.PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null; existingMessage.MessageId = processingAttempt.MessageId; existingMessage.MessageType = messageType; @@ -106,6 +118,8 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe ProcessingAttemptsJson = JsonSerializer.Serialize(attempts, JsonSerializationOptions.Default), FailureGroupsJson = JsonSerializer.Serialize(groups, JsonSerializationOptions.Default), HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers, JsonSerializationOptions.Default), + Body = inlineBody, // Store inline body + Query = BuildSearchableText(processingAttempt.Headers, inlineBody), // Populate Query for all databases PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, MessageId = processingAttempt.MessageId, MessageType = messageType, @@ -122,8 +136,11 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe parent.DbContext.FailedMessages.Add(failedMessageEntity); } - // Store the message body (avoid allocation if body already exists) - await StoreMessageBody(uniqueMessageId, context.Body, contentType, bodySize); + // Store body separately only if it exceeds threshold + if (storeBodySeparately) + { + await StoreMessageBody(uniqueMessageId, context.Body, contentType, bodySize); + } } public async Task RecordSuccessfulRetry(string retriedMessageUniqueId) @@ -186,4 +203,28 @@ 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 string BuildSearchableText(Dictionary headers, byte[]? body) + { + var parts = new List + { + string.Join(" ", headers.Values) // All header values + }; + + // Add body content if present and can be decoded as text + if (body != null && body.Length > 0) + { + try + { + var bodyText = Encoding.UTF8.GetString(body); + parts.Add(bodyText); + } + catch + { + // Skip non-text bodies + } + } + + return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); + } } diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlFullTextSearchProvider.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlFullTextSearchProvider.cs new file mode 100644 index 0000000000..16c508b5b6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlFullTextSearchProvider.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using System.Linq; +using Core.Entities; +using Core.FullTextSearch; +using Microsoft.EntityFrameworkCore; + +class MySqlFullTextSearchProvider : IFullTextSearchProvider +{ + public IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms) + { + return query.Where(fm => EF.Functions.Match(fm.Query, searchTerms, MySqlMatchSearchMode.NaturalLanguage) > 0); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs index 4a1ec37633..3cbfeecbd8 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Persistence.Sql.MySQL; using Core.Abstractions; using Core.DbContexts; +using Core.FullTextSearch; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; @@ -19,12 +20,14 @@ public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); RegisterDataStores(services); + services.AddSingleton(); } public void AddInstaller(IServiceCollection services) { ConfigureDbContext(services); RegisterDataStores(services); + services.AddSingleton(); services.AddSingleton(); } diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs deleted file mode 100644 index 39b41cad63..0000000000 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs +++ /dev/null @@ -1,850 +0,0 @@ -// -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("20251216015817_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.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") - .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("RelatedToJson") - .HasMaxLength(4000) - .HasColumnType("jsonb") - .HasColumnName("related_to_json"); - - 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("jsonb") - .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("jsonb") - .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("ExceptionMessage") - .HasColumnType("text") - .HasColumnName("exception_message"); - - b.Property("ExceptionType") - .HasMaxLength(500) - .HasColumnType("character varying(500)") - .HasColumnName("exception_type"); - - b.Property("FailureGroupsJson") - .IsRequired() - .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"); - - 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("jsonb") - .HasColumnName("processing_attempts_json"); - - 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("MessageId"); - - 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.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") - .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("jsonb") - .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("jsonb") - .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("jsonb") - .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("jsonb") - .HasColumnName("historic_operations_json"); - - b.Property("UnacknowledgedOperationsJson") - .HasColumnType("jsonb") - .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("jsonb") - .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.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") - .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/20251216015817_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs deleted file mode 100644 index e272358e38..0000000000 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs +++ /dev/null @@ -1,572 +0,0 @@ -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: "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 - { - 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_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) - }, - 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: "jsonb", 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: "jsonb", 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: "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), - 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) - }, - 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: "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 - { - 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: "jsonb", 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: "jsonb", 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: "jsonb", 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: "jsonb", nullable: true), - unacknowledged_operations_json = table.Column(type: "jsonb", 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: "jsonb", nullable: false) - }, - constraints: table => - { - 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 - { - 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: "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", - 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_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_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_LicensingMetadata_key", - table: "LicensingMetadata", - column: "key", - unique: true); - - 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); - - migrationBuilder.CreateIndex( - name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", - table: "ThroughputEndpoint", - columns: new[] { "endpoint_name", "throughput_source" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ArchiveOperations"); - - migrationBuilder.DropTable( - name: "CustomChecks"); - - migrationBuilder.DropTable( - name: "DailyThroughput"); - - 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: "LicensingMetadata"); - - 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: "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 deleted file mode 100644 index d68da971bc..0000000000 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ /dev/null @@ -1,847 +0,0 @@ -// -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 - -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.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.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") - .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("RelatedToJson") - .HasMaxLength(4000) - .HasColumnType("jsonb") - .HasColumnName("related_to_json"); - - 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("jsonb") - .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("jsonb") - .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("ExceptionMessage") - .HasColumnType("text") - .HasColumnName("exception_message"); - - b.Property("ExceptionType") - .HasMaxLength(500) - .HasColumnType("character varying(500)") - .HasColumnName("exception_type"); - - b.Property("FailureGroupsJson") - .IsRequired() - .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"); - - 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("jsonb") - .HasColumnName("processing_attempts_json"); - - 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("MessageId"); - - 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.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") - .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("jsonb") - .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("jsonb") - .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("jsonb") - .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("jsonb") - .HasColumnName("historic_operations_json"); - - b.Property("UnacknowledgedOperationsJson") - .HasColumnType("jsonb") - .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("jsonb") - .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.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") - .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/PostgreSqlFullTextSearchProvider.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlFullTextSearchProvider.cs new file mode 100644 index 0000000000..98c16a8c11 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlFullTextSearchProvider.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using System.Linq; +using Core.Entities; +using Core.FullTextSearch; +using Microsoft.EntityFrameworkCore; + +class PostgreSqlFullTextSearchProvider : IFullTextSearchProvider +{ + public IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms) + { + // Convert text to tsvector at query time, use websearch_to_tsquery for user-friendly search + return query.Where(fm => EF.Functions.ToTsVector("english", fm.Query ?? "") + .Matches(EF.Functions.WebSearchToTsQuery("english", searchTerms))); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs index eef0a1a86b..494c87388c 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -2,8 +2,10 @@ namespace ServiceControl.Persistence.Sql.PostgreSQL; using Core.Abstractions; using Core.DbContexts; +using Core.FullTextSearch; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using ServiceControl.Persistence; class PostgreSqlPersistence : BasePersistence, IPersistence @@ -19,12 +21,14 @@ public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); RegisterDataStores(services); + services.AddSingleton(); } public void AddInstaller(IServiceCollection services) { ConfigureDbContext(services); RegisterDataStores(services); + services.AddSingleton(); services.AddSingleton(); } @@ -53,6 +57,8 @@ void ConfigureDbContext(IServiceCollection services) { options.EnableSensitiveDataLogging(); } + + options.LogTo(Console.WriteLine, LogLevel.Warning).EnableDetailedErrors(); }, ServiceLifetime.Scoped); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerFullTextSearchProvider.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerFullTextSearchProvider.cs new file mode 100644 index 0000000000..3454661864 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerFullTextSearchProvider.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using System.Linq; +using Core.Entities; +using Core.FullTextSearch; +using Microsoft.EntityFrameworkCore; + +class SqlServerFullTextSearchProvider : IFullTextSearchProvider +{ + public IQueryable ApplyFullTextSearch( + IQueryable query, + string searchTerms) + { + // Use FREETEXT for natural language full-text search + // EF.Functions.FreeText is available in EF Core for SQL Server + return query.Where(fm => EF.Functions.FreeText(fm.Query ?? "", searchTerms)); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs index 595c81bec0..9a23aa1b28 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Persistence.Sql.SqlServer; using Core.Abstractions; using Core.DbContexts; +using Core.FullTextSearch; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Persistence; @@ -19,12 +20,14 @@ public void AddPersistence(IServiceCollection services) { ConfigureDbContext(services); RegisterDataStores(services); + services.AddSingleton(); } public void AddInstaller(IServiceCollection services) { ConfigureDbContext(services); RegisterDataStores(services); + services.AddSingleton(); // Register the database migrator - this runs during installation/setup services.AddSingleton(); diff --git a/src/ServiceControl.Persistence/PersistenceSettings.cs b/src/ServiceControl.Persistence/PersistenceSettings.cs index d0475801bb..f72b4694f4 100644 --- a/src/ServiceControl.Persistence/PersistenceSettings.cs +++ b/src/ServiceControl.Persistence/PersistenceSettings.cs @@ -13,6 +13,8 @@ public abstract class PersistenceSettings public bool EnableFullTextSearchOnBodies { get; set; } = true; + public int MaxBodySizeToStore { get; set; } = 102400; // 100KB default (matches Audit) + public TimeSpan? OverrideCustomCheckRepeatTime { get; set; } } } \ No newline at end of file From bb584aed150148e83b8218d486fc440ff5017ec3 Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 6 Jan 2026 15:44:48 +1000 Subject: [PATCH 21/23] Adding migrations --- ... 20260106053129_InitialCreate.Designer.cs} | 8 +- ...ate.cs => 20260106053129_InitialCreate.cs} | 3 + ...6053507_AddFullTextSearchIndex.Designer.cs | 716 +++++++++++++++ .../20260106053507_AddFullTextSearchIndex.cs | 27 + .../Migrations/MySqlDbContextModelSnapshot.cs | 6 + ...erviceControl.Persistence.Sql.MySQL.csproj | 8 +- .../20260106052900_InitialCreate.Designer.cs | 858 ++++++++++++++++++ .../20260106052900_InitialCreate.cs | 574 ++++++++++++ ...6053432_AddFullTextSearchIndex.Designer.cs | 858 ++++++++++++++++++ .../20260106053432_AddFullTextSearchIndex.cs | 28 + .../PostgreSqlDbContextModelSnapshot.cs | 855 +++++++++++++++++ ...eControl.Persistence.Sql.PostgreSQL.csproj | 8 +- ... 20260106053230_InitialCreate.Designer.cs} | 8 +- ...ate.cs => 20260106053230_InitialCreate.cs} | 2 + ...6053534_AddFullTextSearchIndex.Designer.cs | 716 +++++++++++++++ .../20260106053534_AddFullTextSearchIndex.cs | 45 + .../SqlServerDbContextModelSnapshot.cs | 6 + ...ceControl.Persistence.Sql.SqlServer.csproj | 8 +- .../Persistence/PersistenceFactory.cs | 25 +- src/ServiceControl/ServiceControl.csproj | 4 +- 20 files changed, 4734 insertions(+), 29 deletions(-) rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20251216015935_InitialCreate.Designer.cs => 20260106053129_InitialCreate.Designer.cs} (99%) rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20251216015935_InitialCreate.cs => 20260106053129_InitialCreate.cs} (99%) create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.cs create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20251216020009_InitialCreate.Designer.cs => 20260106053230_InitialCreate.Designer.cs} (99%) rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20251216020009_InitialCreate.cs => 20260106053230_InitialCreate.cs} (99%) create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.Designer.cs create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.cs diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.Designer.cs similarity index 99% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.Designer.cs index 1d689bf060..80a43fffdc 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.MySQL.Migrations { [DbContext(typeof(MySqlDbContext))] - [Migration("20251216015935_InitialCreate")] + [Migration("20260106053129_InitialCreate")] partial class InitialCreate { /// @@ -251,6 +251,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("Body") + .HasColumnType("longblob"); + b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("varchar(200)"); @@ -292,6 +295,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("json"); + b.Property("Query") + .HasColumnType("longtext"); + b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("varchar(500)"); diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs similarity index 99% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs index 16c6ba2775..e32eb51684 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs @@ -183,6 +183,9 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"), HeadersJson = table.Column(type: "json", nullable: false) .Annotation("MySql:CharSet", "utf8mb4"), + Body = table.Column(type: "longblob", nullable: true), + Query = table.Column(type: "longtext", nullable: true) + .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) diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.Designer.cs new file mode 100644 index 0000000000..f7fc2efdda --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.Designer.cs @@ -0,0 +1,716 @@ +// +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("20260106053507_AddFullTextSearchIndex")] + partial class AddFullTextSearchIndex + { + /// + 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.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") + .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("RelatedToJson") + .HasMaxLength(4000) + .HasColumnType("json"); + + 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("json"); + + 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("json"); + + 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("Body") + .HasColumnType("longblob"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExceptionMessage") + .HasColumnType("longtext"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("json"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("json"); + + 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("json"); + + b.Property("Query") + .HasColumnType("longtext"); + + 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("MessageId"); + + 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.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") + .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("json"); + + 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("json"); + + 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("json"); + + 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("json"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("json"); + + 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("json"); + + b.HasKey("Id"); + + b.HasIndex("MessageTypeTypeName", "MessageTypeVersion") + .IsUnique(); + + 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") + .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/20260106053507_AddFullTextSearchIndex.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.cs new file mode 100644 index 0000000000..b1f5a7a470 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053507_AddFullTextSearchIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + /// + public partial class AddFullTextSearchIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Full-text search index using MySQL's FULLTEXT index + // This enables efficient MATCH...AGAINST searches on the Query column + migrationBuilder.Sql( + @"CREATE FULLTEXT INDEX IX_FailedMessages_Query_FTS + ON FailedMessages(Query)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Drop full-text search index + migrationBuilder.Sql(@"DROP INDEX IF EXISTS IX_FailedMessages_Query_FTS ON FailedMessages"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs index 15a01b6c39..8418b8a924 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -248,6 +248,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("Body") + .HasColumnType("longblob"); + b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("varchar(200)"); @@ -289,6 +292,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("json"); + b.Property("Query") + .HasColumnType("longtext"); + b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("varchar(500)"); diff --git a/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj index 5cdc8c7401..ca8acb81aa 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj +++ b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj @@ -15,10 +15,10 @@ - - - - + + + + diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs new file mode 100644 index 0000000000..25787d809f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs @@ -0,0 +1,858 @@ +// +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("20260106052900_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.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") + .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("RelatedToJson") + .HasMaxLength(4000) + .HasColumnType("jsonb") + .HasColumnName("related_to_json"); + + 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("jsonb") + .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("jsonb") + .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("Body") + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("conversation_id"); + + 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("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"); + + 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("jsonb") + .HasColumnName("processing_attempts_json"); + + b.Property("Query") + .HasColumnType("text") + .HasColumnName("query"); + + 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("MessageId"); + + 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.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") + .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("jsonb") + .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("jsonb") + .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("jsonb") + .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("jsonb") + .HasColumnName("historic_operations_json"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("jsonb") + .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("jsonb") + .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.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") + .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/20260106052900_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs new file mode 100644 index 0000000000..0b1076249c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs @@ -0,0 +1,574 @@ +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: "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 + { + 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_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) + }, + 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: "jsonb", 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: "jsonb", 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: "jsonb", nullable: false), + failure_groups_json = table.Column(type: "jsonb", nullable: false), + headers_json = table.Column(type: "jsonb", nullable: false), + body = table.Column(type: "bytea", nullable: true), + query = table.Column(type: "text", nullable: true), + 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) + }, + 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: "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 + { + 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: "jsonb", 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: "jsonb", 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: "jsonb", 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: "jsonb", nullable: true), + unacknowledged_operations_json = table.Column(type: "jsonb", 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: "jsonb", nullable: false) + }, + constraints: table => + { + 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 + { + 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: "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", + 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_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_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_LicensingMetadata_key", + table: "LicensingMetadata", + column: "key", + unique: true); + + 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); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "endpoint_name", "throughput_source" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveOperations"); + + migrationBuilder.DropTable( + name: "CustomChecks"); + + migrationBuilder.DropTable( + name: "DailyThroughput"); + + 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: "LicensingMetadata"); + + 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: "ThroughputEndpoint"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.Designer.cs new file mode 100644 index 0000000000..b82093b2a9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.Designer.cs @@ -0,0 +1,858 @@ +// +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("20260106053432_AddFullTextSearchIndex")] + partial class AddFullTextSearchIndex + { + /// + 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.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") + .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("RelatedToJson") + .HasMaxLength(4000) + .HasColumnType("jsonb") + .HasColumnName("related_to_json"); + + 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("jsonb") + .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("jsonb") + .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("Body") + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("conversation_id"); + + 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("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"); + + 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("jsonb") + .HasColumnName("processing_attempts_json"); + + b.Property("Query") + .HasColumnType("text") + .HasColumnName("query"); + + 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("MessageId"); + + 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.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") + .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("jsonb") + .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("jsonb") + .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("jsonb") + .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("jsonb") + .HasColumnName("historic_operations_json"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("jsonb") + .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("jsonb") + .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.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") + .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/20260106053432_AddFullTextSearchIndex.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.cs new file mode 100644 index 0000000000..e5f6b8df4e --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106053432_AddFullTextSearchIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + /// + public partial class AddFullTextSearchIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Full-text search index using PostgreSQL's GIN index on tsvector expression + // This enables efficient full-text search on the Query column using to_tsvector and websearch_to_tsquery + migrationBuilder.Sql( + @"CREATE INDEX IX_FailedMessages_query_fts + ON ""FailedMessages"" + USING GIN (to_tsvector('english', COALESCE(query, '')))"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Drop full-text search index + migrationBuilder.Sql(@"DROP INDEX IF EXISTS IX_FailedMessages_query_fts"); + } + } +} 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..d40937b4fe --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -0,0 +1,855 @@ +// +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 + +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.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.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") + .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("RelatedToJson") + .HasMaxLength(4000) + .HasColumnType("jsonb") + .HasColumnName("related_to_json"); + + 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("jsonb") + .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("jsonb") + .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("Body") + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("conversation_id"); + + 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("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"); + + 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("jsonb") + .HasColumnName("processing_attempts_json"); + + b.Property("Query") + .HasColumnType("text") + .HasColumnName("query"); + + 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("MessageId"); + + 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.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") + .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("jsonb") + .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("jsonb") + .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("jsonb") + .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("jsonb") + .HasColumnName("historic_operations_json"); + + b.Property("UnacknowledgedOperationsJson") + .HasColumnType("jsonb") + .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("jsonb") + .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.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") + .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/ServiceControl.Persistence.Sql.PostgreSQL.csproj b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj index 3add7d15e1..e3fb42fd4b 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj @@ -15,10 +15,10 @@ - - - - + + + + diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.Designer.cs similarity index 99% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.Designer.cs index f954d12a29..3805bfd902 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.SqlServer.Migrations { [DbContext(typeof(SqlServerDbContext))] - [Migration("20251216020009_InitialCreate")] + [Migration("20260106053230_InitialCreate")] partial class InitialCreate { /// @@ -251,6 +251,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("Body") + .HasColumnType("varbinary(max)"); + b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -292,6 +295,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("Query") + .HasColumnType("nvarchar(max)"); + b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("nvarchar(500)"); diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs similarity index 99% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs index 196eb3ac47..dd85b09c5a 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs @@ -148,6 +148,8 @@ protected override void Up(MigrationBuilder migrationBuilder) ProcessingAttemptsJson = table.Column(type: "nvarchar(max)", nullable: false), FailureGroupsJson = table.Column(type: "nvarchar(max)", nullable: false), HeadersJson = table.Column(type: "nvarchar(max)", nullable: false), + Body = table.Column(type: "varbinary(max)", nullable: true), + Query = table.Column(type: "nvarchar(max)", nullable: true), 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), diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.Designer.cs new file mode 100644 index 0000000000..3b485a43a6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.Designer.cs @@ -0,0 +1,716 @@ +// +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("20260106053534_AddFullTextSearchIndex")] + partial class AddFullTextSearchIndex + { + /// + 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.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") + .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("RelatedToJson") + .HasMaxLength(4000) + .HasColumnType("nvarchar(max)"); + + 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("Body") + .HasColumnType("varbinary(max)"); + + b.Property("ConversationId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExceptionMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("ExceptionType") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FailureGroupsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HeadersJson") + .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("Query") + .HasColumnType("nvarchar(max)"); + + 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("MessageId"); + + 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.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") + .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.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") + .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/20260106053534_AddFullTextSearchIndex.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.cs new file mode 100644 index 0000000000..d185e6d66f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053534_AddFullTextSearchIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + /// + public partial class AddFullTextSearchIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Full-text search catalog and index using SQL Server Full-Text Search + // This enables efficient FREETEXT searches on the Query column + migrationBuilder.Sql( + @"IF NOT EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ServiceControlCatalog') + BEGIN + CREATE FULLTEXT CATALOG ServiceControlCatalog AS DEFAULT; + END"); + + migrationBuilder.Sql( + @"CREATE FULLTEXT INDEX ON FailedMessages(Query LANGUAGE 1033) + KEY INDEX PK_FailedMessages + ON ServiceControlCatalog + WITH CHANGE_TRACKING AUTO"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Drop full-text search index and catalog + migrationBuilder.Sql( + @"IF EXISTS (SELECT * FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('FailedMessages')) + BEGIN + DROP FULLTEXT INDEX ON FailedMessages; + END"); + + migrationBuilder.Sql( + @"IF EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ServiceControlCatalog') + BEGIN + DROP FULLTEXT CATALOG ServiceControlCatalog; + END"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs index d108aef281..221a15ba62 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -248,6 +248,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("Body") + .HasColumnType("varbinary(max)"); + b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -289,6 +292,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("Query") + .HasColumnType("nvarchar(max)"); + b.Property("QueueAddress") .HasMaxLength(500) .HasColumnType("nvarchar(500)"); diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj b/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj index 50e155fead..46d60ae6f3 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj +++ b/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj @@ -15,10 +15,10 @@ - - - - + + + + diff --git a/src/ServiceControl/Persistence/PersistenceFactory.cs b/src/ServiceControl/Persistence/PersistenceFactory.cs index 745faa5ef3..6ce29998bb 100644 --- a/src/ServiceControl/Persistence/PersistenceFactory.cs +++ b/src/ServiceControl/Persistence/PersistenceFactory.cs @@ -1,8 +1,10 @@ namespace ServiceControl.Persistence { using System; - using System.IO; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Persistence.Sql.MySQL; + using ServiceControl.Persistence.Sql.PostgreSQL; + using ServiceControl.Persistence.Sql.SqlServer; static class PersistenceFactory { @@ -20,19 +22,14 @@ public static IPersistence Create(Settings settings, bool maintenanceMode = fals static IPersistenceConfiguration CreatePersistenceConfiguration(Settings settings) { - try + return settings.PersistenceType switch { - var persistenceManifest = PersistenceManifestLibrary.Find(settings.PersistenceType); - var assemblyPath = Path.Combine(persistenceManifest.Location, $"{persistenceManifest.AssemblyName}.dll"); - var loadContext = settings.AssemblyLoadContextResolver(assemblyPath); - var customizationType = Type.GetType(persistenceManifest.TypeName, loadContext.LoadFromAssemblyName, null, true); - - return (IPersistenceConfiguration)Activator.CreateInstance(customizationType); - } - catch (Exception e) - { - throw new Exception($"Could not load persistence customization type {settings.PersistenceType}.", e); - } + "PostgreSQL" => new PostgreSqlPersistenceConfiguration(), + "MySQL" => new MySqlPersistenceConfiguration(), + "SqlServer" => new SqlServerPersistenceConfiguration(), + _ => throw new Exception($"Unsupported persistence type {settings.PersistenceType}."), + }; } + } -} \ No newline at end of file +} diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 04f5956ccf..7bc61af43a 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -12,10 +12,12 @@ - + + + From 9358ce0bf2828e1b42f7dad76851d60fa9b3042a Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 6 Jan 2026 16:14:32 +1000 Subject: [PATCH 22/23] make external integrations more efficient --- .../ExternalIntegrationRequestsDataStore.cs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs index 90257b4daf..876b91461d 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs @@ -18,6 +18,7 @@ public class ExternalIntegrationRequestsDataStore : DataStoreBase, IExternalInte { readonly ILogger logger; readonly CancellationTokenSource tokenSource = new(); + readonly SemaphoreSlim workSignal = new(0); Func? callback; Task? dispatcherTask; @@ -30,9 +31,9 @@ public ExternalIntegrationRequestsDataStore( this.logger = logger; } - public Task StoreDispatchRequest(IEnumerable dispatchRequests) + public async Task StoreDispatchRequest(IEnumerable dispatchRequests) { - return ExecuteWithDbContext(async dbContext => + await ExecuteWithDbContext(async dbContext => { foreach (var dispatchRequest in dispatchRequests) { @@ -52,6 +53,9 @@ public Task StoreDispatchRequest(IEnumerable await dbContext.SaveChangesAsync(); }); + + // Signal that work is available + _ = workSignal.Release(); } public void Subscribe(Func callback) @@ -75,10 +79,18 @@ async Task DispatcherLoop(CancellationToken cancellationToken) { try { - await DispatchBatch(cancellationToken); - - // Wait before checking for more events - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + // Wait for signal that work is available + await workSignal.WaitAsync(cancellationToken); + + // Process all available batches until queue is drained + while (!cancellationToken.IsCancellationRequested) + { + var hasMoreWork = await DispatchBatch(cancellationToken); + if (!hasMoreWork) + { + break; + } + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -97,9 +109,9 @@ async Task DispatcherLoop(CancellationToken cancellationToken) } } - async Task DispatchBatch(CancellationToken cancellationToken) + async Task DispatchBatch(CancellationToken cancellationToken) { - await ExecuteWithDbContext(async dbContext => + return await ExecuteWithDbContext(async dbContext => { var batchSize = 100; // Default batch size var requests = await dbContext.ExternalIntegrationDispatchRequests @@ -109,7 +121,7 @@ await ExecuteWithDbContext(async dbContext => if (requests.Count == 0) { - return; + return false; } var contexts = requests @@ -126,6 +138,9 @@ await ExecuteWithDbContext(async dbContext => // Remove dispatched requests dbContext.ExternalIntegrationDispatchRequests.RemoveRange(requests); await dbContext.SaveChangesAsync(cancellationToken); + + // Return true if we processed a full batch (might be more work available) + return requests.Count == batchSize; }); } @@ -147,5 +162,6 @@ public async ValueTask DisposeAsync() } tokenSource?.Dispose(); + workSignal?.Dispose(); } } From 2096a57825c1a546279ce0291347dd3bfa9bb48f Mon Sep 17 00:00:00 2001 From: John Simons Date: Thu, 8 Jan 2026 10:53:53 +1000 Subject: [PATCH 23/23] improve message body storage --- .../Abstractions/BasePersistence.cs | 1 + .../DbContexts/ServiceControlDbContextBase.cs | 2 - .../Entities/FailedMessageEntity.cs | 5 +- .../Entities/MessageBodyEntity.cs | 12 - .../FailedMessageConfiguration.cs | 3 +- .../MessageBodyConfiguration.cs | 19 - .../Implementation/BodyStorage.cs | 34 +- .../Implementation/ErrorMessageDataStore.cs | 24 +- .../FileSystemBodyStorageHelper.cs | 156 ++++ .../Implementation/LicensingDataStore.cs | 16 +- .../UnitOfWork/IngestionUnitOfWork.cs | 4 +- .../UnitOfWork/IngestionUnitOfWorkFactory.cs | 4 +- .../RecoverabilityIngestionUnitOfWork.cs | 71 +- .../20260106053129_InitialCreate.cs | 664 ------------------ ... 20260107065436_InitialCreate.Designer.cs} | 32 +- .../20260107065436_InitialCreate.cs | 40 ++ .../Migrations/MySqlDbContextModelSnapshot.cs | 30 - .../20260106052900_InitialCreate.cs | 574 --------------- ... 20260107065432_InitialCreate.Designer.cs} | 39 +- .../20260107065432_InitialCreate.cs | 37 + .../PostgreSqlDbContextModelSnapshot.cs | 37 - .../20260106053230_InitialCreate.cs | 573 --------------- ... 20260107065422_InitialCreate.Designer.cs} | 32 +- .../20260107065422_InitialCreate.cs | 37 + .../SqlServerDbContextModelSnapshot.cs | 30 - .../SqlServerDatabaseMigrator.cs | 2 +- .../SqlServerPersistenceConfiguration.cs | 12 + .../PersistenceSettings.cs | 13 +- .../Hosting/Commands/SetupCommand.cs | 22 +- 29 files changed, 385 insertions(+), 2140 deletions(-) delete mode 100644 src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs delete mode 100644 src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Sql.Core/Implementation/FileSystemBodyStorageHelper.cs delete mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs rename src/ServiceControl.Persistence.Sql.MySQL/Migrations/{20260106053129_InitialCreate.Designer.cs => 20260107065436_InitialCreate.Designer.cs} (95%) create mode 100644 src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.cs delete mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs rename src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/{20260106052900_InitialCreate.Designer.cs => 20260107065432_InitialCreate.Designer.cs} (95%) create mode 100644 src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.cs delete mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs rename src/ServiceControl.Persistence.Sql.SqlServer/Migrations/{20260106053230_InitialCreate.Designer.cs => 20260107065422_InitialCreate.Designer.cs} (95%) create mode 100644 src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.cs diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs index cc3cba6cd6..fe1318c950 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs @@ -33,5 +33,6 @@ protected static void RegisterDataStores(IServiceCollection services) 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 f6578ab12d..b06dbb4f74 100644 --- a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -19,7 +19,6 @@ protected ServiceControlDbContextBase(DbContextOptions options) : base(options) 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; } @@ -52,7 +51,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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()); diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs index 13caee5741..eb5f49e5ac 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs @@ -14,10 +14,7 @@ public class FailedMessageEntity public string FailureGroupsJson { get; set; } = null!; public string HeadersJson { get; set; } = null!; - // Inline body storage for small messages (below MaxBodySizeToStore threshold) - public byte[]? Body { get; set; } - - // Full-text search column (populated from headers + inline body) + // Full-text search column (populated from headers and body) public string? Query { get; set; } // Denormalized fields from FailureGroups for efficient filtering diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs deleted file mode 100644 index d5d1531acc..0000000000 --- a/src/ServiceControl.Persistence.Sql.Core/Entities/MessageBodyEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -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/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs index afcd4f30f2..7623846cb0 100644 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs @@ -17,8 +17,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.FailureGroupsJson).HasColumnType("jsonb").IsRequired(); builder.Property(e => e.HeadersJson).HasColumnType("jsonb").IsRequired(); - // Full-text search and inline body storage - builder.Property(e => e.Body); // Will be mapped to bytea/longblob/varbinary(max) per database + // Full-text search builder.Property(e => e.Query); // Will be mapped to text/nvarchar(max) per database // Denormalized query fields from FailureGroups diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs deleted file mode 100644 index 3b918cbfe4..0000000000 --- a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageBodyConfiguration.cs +++ /dev/null @@ -1,19 +0,0 @@ -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/Implementation/BodyStorage.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs index 291b2aea00..fb5871adf5 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/BodyStorage.cs @@ -1,28 +1,24 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; -using System; -using System.IO; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ServiceControl.Operations.BodyStorage; public class BodyStorage : DataStoreBase, IBodyStorage { - public BodyStorage(IServiceScopeFactory scopeFactory) : base(scopeFactory) + readonly FileSystemBodyStorageHelper storageHelper; + + public BodyStorage(IServiceScopeFactory scopeFactory, FileSystemBodyStorageHelper storageHelper) : base(scopeFactory) { + this.storageHelper = storageHelper; } - - public Task TryFetch(string bodyId) + public async Task TryFetch(string bodyId) { - return ExecuteWithDbContext(async dbContext => + try { - // Try to fetch the body directly by ID - var messageBody = await dbContext.MessageBodies - .AsNoTracking() - .FirstOrDefaultAsync(mb => mb.Id == Guid.Parse(bodyId)); + var result = await storageHelper.ReadBodyAsync(bodyId); - if (messageBody == null) + if (result == null) { return new MessageBodyStreamResult { HasResult = false }; } @@ -30,11 +26,15 @@ public Task TryFetch(string bodyId) return new MessageBodyStreamResult { HasResult = true, - Stream = new MemoryStream(messageBody.Body), - ContentType = messageBody.ContentType, - BodySize = messageBody.BodySize, - Etag = messageBody.Etag + Stream = result.Stream, // Already positioned, decompression handled + ContentType = result.ContentType, + BodySize = result.BodySize, + Etag = result.Etag }; - }); + } + catch + { + return new MessageBodyStreamResult { HasResult = false }; + } } } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs index 208fade35d..dbb8b07c73 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -18,10 +18,12 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation; partial class ErrorMessageDataStore : DataStoreBase, IErrorMessageDataStore { readonly IFullTextSearchProvider fullTextSearchProvider; + readonly FileSystemBodyStorageHelper storageHelper; - public ErrorMessageDataStore(IServiceScopeFactory scopeFactory, IFullTextSearchProvider fullTextSearchProvider) : base(scopeFactory) + public ErrorMessageDataStore(IServiceScopeFactory scopeFactory, IFullTextSearchProvider fullTextSearchProvider, FileSystemBodyStorageHelper storageHelper) : base(scopeFactory) { this.fullTextSearchProvider = fullTextSearchProvider; + this.storageHelper = storageHelper; } public Task FailedMessagesFetch(Guid[] ids) @@ -97,7 +99,6 @@ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessage ProcessingAttemptsJson = JsonSerializer.Serialize(failedMessage.ProcessingAttempts, JsonSerializationOptions.Default), FailureGroupsJson = JsonSerializer.Serialize(failedMessage.FailureGroups, JsonSerializationOptions.Default), HeadersJson = JsonSerializer.Serialize(lastAttempt?.Headers ?? [], JsonSerializationOptions.Default), - Body = null, // Test data doesn't include inline bodies Query = null, // Test data doesn't populate search text PrimaryFailureGroupId = failedMessage.FailureGroups.Count > 0 ? failedMessage.FailureGroups[0].Id : null, @@ -122,15 +123,18 @@ public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessage }); } - public Task FetchFromFailedMessage(string uniqueMessageId) + public async Task FetchFromFailedMessage(string uniqueMessageId) { - return ExecuteWithDbContext(async dbContext => + var result = await storageHelper.ReadBodyAsync(uniqueMessageId); + if (result != null) { - var messageBody = await dbContext.MessageBodies - .AsNoTracking() - .FirstOrDefaultAsync(mb => mb.Id == Guid.Parse(uniqueMessageId)); - - return messageBody?.Body!; - }); + // Allocate exact size needed and read directly into it + var buffer = new byte[result.BodySize]; + await result.Stream.ReadExactlyAsync(buffer); + result.Stream.Dispose(); + return buffer; + } + + return Array.Empty(); } } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/FileSystemBodyStorageHelper.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/FileSystemBodyStorageHelper.cs new file mode 100644 index 0000000000..3a38c75113 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/FileSystemBodyStorageHelper.cs @@ -0,0 +1,156 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +public class FileSystemBodyStorageHelper(PersistenceSettings settings) +{ + const int FormatVersion = 1; + + public async Task WriteBodyAsync( + string bodyId, + ReadOnlyMemory body, + string contentType, + CancellationToken cancellationToken = default) + { + var filePath = Path.Combine(settings.MessageBodyStoragePath, $"{bodyId}.body"); + + // Bodies are immutable - skip if file already exists + if (File.Exists(filePath)) + { + return; + } + + // Write to temp file first for atomic operation + var tempFilePath = filePath + ".tmp"; + + try + { + await using var fileStream = new FileStream( + tempFilePath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true); + + await using var writer = new BinaryWriter(fileStream, System.Text.Encoding.UTF8, leaveOpen: true); + + var shouldCompress = body.Length >= settings.MinBodySizeForCompression; + + // Write header + writer.Write(FormatVersion); + writer.Write(contentType); + writer.Write(body.Length); // Original uncompressed size + writer.Write(shouldCompress); + writer.Write(Guid.NewGuid().ToString()); // Generate ETag + + // Flush the header before writing body + writer.Flush(); + + // Write body (compressed or not) + if (shouldCompress) + { + await using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal, leaveOpen: true); + await gzipStream.WriteAsync(body, cancellationToken); + } + else + { + await fileStream.WriteAsync(body, cancellationToken); + } + + await fileStream.FlushAsync(cancellationToken); + + // Atomic rename + File.Move(tempFilePath, filePath, overwrite: false); + } + catch + { + // Clean up temp file if it exists + if (File.Exists(tempFilePath)) + { + try + { + File.Delete(tempFilePath); + } + catch + { + // Ignore cleanup errors + } + } + throw; + } + } + + public Task ReadBodyAsync(string bodyId) + { + var filePath = Path.Combine(settings.MessageBodyStoragePath, $"{bodyId}.body"); + + if (!File.Exists(filePath)) + { + return Task.FromResult(null); + } + + try + { + var fileStream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true); + + var reader = new BinaryReader(fileStream, System.Text.Encoding.UTF8, leaveOpen: true); + + // Read header + var formatVersion = reader.ReadInt32(); + if (formatVersion != FormatVersion) + { + fileStream.Dispose(); + throw new InvalidOperationException($"Unsupported body file format version: {formatVersion}"); + } + + var contentType = reader.ReadString(); + var bodySize = reader.ReadInt32(); + var isCompressed = reader.ReadBoolean(); + var etag = reader.ReadString(); + + // Create appropriate stream wrapper for body data + Stream bodyStream = fileStream; + if (isCompressed) + { + bodyStream = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: false); + } + + var result = new MessageBodyFileResult + { + Stream = bodyStream, + ContentType = contentType, + BodySize = bodySize, + Etag = etag + }; + + return Task.FromResult(result); + } + catch (FileNotFoundException) + { + return Task.FromResult(null); + } + catch (IOException ex) + { + throw new InvalidOperationException($"Failed to read body file for {bodyId}", ex); + } + } + + public class MessageBodyFileResult + { + public Stream Stream { get; set; } = null!; + public string ContentType { get; set; } = null!; + public int BodySize { get; set; } + public string Etag { get; set; } = null!; + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs index 5776cdd766..c3b9e332b5 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs @@ -107,10 +107,15 @@ public Task RecordEndpointThroughput(string endpointName, ThroughputSource throu #region Endpoints public Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken) { + var mapped = endpointIds.Select(id => new + { + id.Name, + Source = Enum.GetName(id.ThroughputSource) + }); 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)) + .Where(e => mapped.Any(id => id.Name == e.EndpointName && id.Source == e.ThroughputSource)) .ToListAsync(cancellationToken); var lookup = fromDatabase.Select(MapEndpointEntityToContract).ToLookup(e => e.Id); @@ -121,9 +126,10 @@ public Task RecordEndpointThroughput(string endpointName, ThroughputSource throu public Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) { + var throughputSource = Enum.GetName(id.ThroughputSource); return ExecuteWithDbContext(async dbContext => { - var fromDatabase = await dbContext.Endpoints.AsNoTracking().SingleOrDefaultAsync(e => e.EndpointName == id.Name && e.ThroughputSource == Enum.GetName(id.ThroughputSource), cancellationToken); + var fromDatabase = await dbContext.Endpoints.AsNoTracking().SingleOrDefaultAsync(e => e.EndpointName == id.Name && e.ThroughputSource == throughputSource, cancellationToken); if (fromDatabase is null) { return null; @@ -140,7 +146,8 @@ public Task> GetAllEndpoints(bool includePlatformEndpoints var endpoints = dbContext.Endpoints.AsNoTracking(); if (!includePlatformEndpoints) { - endpoints = endpoints.Where(x => x.EndpointIndicators == null || !x.EndpointIndicators.Contains(Enum.GetName(EndpointIndicator.PlatformEndpoint)!)); + var source = Enum.GetName(EndpointIndicator.PlatformEndpoint); + endpoints = endpoints.Where(x => x.EndpointIndicators == null || !x.EndpointIndicators.Contains(source!)); } var fromDatabase = await endpoints.ToListAsync(cancellationToken); @@ -151,9 +158,10 @@ public Task> GetAllEndpoints(bool includePlatformEndpoints public Task SaveEndpoint(Endpoint endpoint, CancellationToken cancellationToken) { + var source = Enum.GetName(endpoint.Id.ThroughputSource); return ExecuteWithDbContext(async dbContext => { - var existing = await dbContext.Endpoints.SingleOrDefaultAsync(e => e.EndpointName == endpoint.Id.Name && e.ThroughputSource == Enum.GetName(endpoint.Id.ThroughputSource), cancellationToken); + var existing = await dbContext.Endpoints.SingleOrDefaultAsync(e => e.EndpointName == endpoint.Id.Name && e.ThroughputSource == source, cancellationToken); if (existing is null) { var entity = MapEndpointContractToEntity(endpoint); diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs index 210a68d11f..64ed9dfc6d 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWork.cs @@ -8,12 +8,12 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; class IngestionUnitOfWork : IngestionUnitOfWorkBase { - public IngestionUnitOfWork(ServiceControlDbContextBase dbContext, PersistenceSettings settings) + public IngestionUnitOfWork(ServiceControlDbContextBase dbContext, FileSystemBodyStorageHelper storageHelper, PersistenceSettings settings) { DbContext = dbContext; Settings = settings; Monitoring = new MonitoringIngestionUnitOfWork(this); - Recoverability = new RecoverabilityIngestionUnitOfWork(this); + Recoverability = new RecoverabilityIngestionUnitOfWork(this, storageHelper); } internal ServiceControlDbContextBase DbContext { get; } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs index 64aff3bf0b..d5bccdb079 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/IngestionUnitOfWorkFactory.cs @@ -7,14 +7,14 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; using ServiceControl.Persistence; using ServiceControl.Persistence.UnitOfWork; -class IngestionUnitOfWorkFactory(IServiceProvider serviceProvider, MinimumRequiredStorageState storageState) : IIngestionUnitOfWorkFactory +class IngestionUnitOfWorkFactory(IServiceProvider serviceProvider, MinimumRequiredStorageState storageState, FileSystemBodyStorageHelper storageHelper) : IIngestionUnitOfWorkFactory { public ValueTask StartNew() { var scope = serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); - var unitOfWork = new IngestionUnitOfWork(dbContext, settings); + var unitOfWork = new IngestionUnitOfWork(dbContext, storageHelper, settings); return ValueTask.FromResult(unitOfWork); } diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs index f696d13759..be1f4e8dca 100644 --- a/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -17,9 +17,12 @@ namespace ServiceControl.Persistence.Sql.Core.Implementation.UnitOfWork; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.UnitOfWork; -class RecoverabilityIngestionUnitOfWork(IngestionUnitOfWork parent) : IRecoverabilityIngestionUnitOfWork +class RecoverabilityIngestionUnitOfWork(IngestionUnitOfWork parent, FileSystemBodyStorageHelper storageHelper) : IRecoverabilityIngestionUnitOfWork { const int MaxProcessingAttempts = 10; + // large object heap starts above 85000 bytes and not above 85 KB! + const int LargeObjectHeapThreshold = 85_000; + static readonly Encoding utf8 = new UTF8Encoding(true, true); public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMessage.ProcessingAttempt processingAttempt, List groups) { @@ -37,20 +40,10 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe var uniqueMessageId = context.Headers.UniqueId(); var contentType = GetContentType(context.Headers, MediaTypeNames.Text.Plain); - var bodySize = context.Body.Length; - - // Determine if body should be stored inline based on size threshold - byte[]? inlineBody = null; - bool storeBodySeparately = bodySize > parent.Settings.MaxBodySizeToStore; - - if (!storeBodySeparately && !context.Body.IsEmpty) - { - inlineBody = context.Body.ToArray(); // Store inline - } // Add metadata to the processing attempt processingAttempt.MessageMetadata.Add("ContentType", contentType); - processingAttempt.MessageMetadata.Add("ContentLength", bodySize); + processingAttempt.MessageMetadata.Add("ContentLength", context.Body.Length); processingAttempt.MessageMetadata.Add("BodyUrl", $"/messages/{uniqueMessageId}/body"); @@ -89,8 +82,7 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe existingMessage.ProcessingAttemptsJson = JsonSerializer.Serialize(attempts, JsonSerializationOptions.Default); existingMessage.FailureGroupsJson = JsonSerializer.Serialize(groups, JsonSerializationOptions.Default); existingMessage.HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers, JsonSerializationOptions.Default); - existingMessage.Body = inlineBody; // Update inline body - existingMessage.Query = BuildSearchableText(processingAttempt.Headers, inlineBody); // Populate Query for all databases + existingMessage.Query = BuildSearchableText(processingAttempt.Headers, context.Body); // Populate Query for all databases existingMessage.PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null; existingMessage.MessageId = processingAttempt.MessageId; existingMessage.MessageType = messageType; @@ -118,8 +110,7 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe ProcessingAttemptsJson = JsonSerializer.Serialize(attempts, JsonSerializationOptions.Default), FailureGroupsJson = JsonSerializer.Serialize(groups, JsonSerializationOptions.Default), HeadersJson = JsonSerializer.Serialize(processingAttempt.Headers, JsonSerializationOptions.Default), - Body = inlineBody, // Store inline body - Query = BuildSearchableText(processingAttempt.Headers, inlineBody), // Populate Query for all databases + Query = BuildSearchableText(processingAttempt.Headers, context.Body), // Populate Query for all databases PrimaryFailureGroupId = groups.Count > 0 ? groups[0].Id : null, MessageId = processingAttempt.MessageId, MessageType = messageType, @@ -136,11 +127,9 @@ public async Task RecordFailedProcessingAttempt(MessageContext context, FailedMe parent.DbContext.FailedMessages.Add(failedMessageEntity); } - // Store body separately only if it exceeds threshold - if (storeBodySeparately) - { - await StoreMessageBody(uniqueMessageId, context.Body, contentType, bodySize); - } + // ALWAYS store to filesystem (regardless of size) + var shouldCompress = context.Body.Length >= parent.Settings.MinBodySizeForCompression; + await storageHelper.WriteBodyAsync(uniqueMessageId, context.Body, contentType); } public async Task RecordSuccessfulRetry(string retriedMessageUniqueId) @@ -173,55 +162,29 @@ public async Task RecordSuccessfulRetry(string retriedMessageUniqueId) } } - 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 string BuildSearchableText(Dictionary headers, byte[]? body) + static string BuildSearchableText(Dictionary headers, ReadOnlyMemory body) { var parts = new List { string.Join(" ", headers.Values) // All header values }; - // Add body content if present and can be decoded as text - if (body != null && body.Length > 0) + var avoidsLargeObjectHeap = body.Length < LargeObjectHeapThreshold; + var isBinary = headers.IsBinary(); + if (avoidsLargeObjectHeap && !isBinary) { try { - var bodyText = Encoding.UTF8.GetString(body); - parts.Add(bodyText); + var bodyString = utf8.GetString(body.Span); + parts.Add(bodyString); } catch { - // Skip non-text bodies + // If it won't decode to text, don't index it } } diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs deleted file mode 100644 index e32eb51684..0000000000 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.cs +++ /dev/null @@ -1,664 +0,0 @@ -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: "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 - { - 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), - 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"), - 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: "json", 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: "json", 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: "json", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - FailureGroupsJson = table.Column(type: "json", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - HeadersJson = table.Column(type: "json", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - Body = table.Column(type: "longblob", nullable: true), - Query = table.Column(type: "longtext", nullable: true) - .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") - }, - 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: "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 - { - 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: "json", 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: "json", 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: "json", 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: "json", nullable: true) - .Annotation("MySql:CharSet", "utf8mb4"), - UnacknowledgedOperationsJson = table.Column(type: "json", 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: "json", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4") - }, - constraints: table => - { - table.PrimaryKey("PK_Subscriptions", x => x.Id); - }) - .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 - { - 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: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", - table: "DailyThroughput", - columns: new[] { "EndpointName", "ThroughputSource", "Date" }, - unique: true); - - 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_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_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_LicensingMetadata_Key", - table: "LicensingMetadata", - column: "Key", - unique: true); - - 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); - - migrationBuilder.CreateIndex( - name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", - table: "ThroughputEndpoint", - columns: new[] { "EndpointName", "ThroughputSource" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ArchiveOperations"); - - migrationBuilder.DropTable( - name: "CustomChecks"); - - migrationBuilder.DropTable( - name: "DailyThroughput"); - - 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: "LicensingMetadata"); - - 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: "ThroughputEndpoint"); - - migrationBuilder.DropTable( - name: "TrialLicense"); - } - } -} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.Designer.cs similarity index 95% rename from src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.Designer.cs index 80a43fffdc..b64b1fc083 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260106053129_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.MySQL.Migrations { [DbContext(typeof(MySqlDbContext))] - [Migration("20260106053129_InitialCreate")] + [Migration("20260107065436_InitialCreate")] partial class InitialCreate { /// @@ -251,9 +251,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.Property("Body") - .HasColumnType("longblob"); - b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("varchar(200)"); @@ -454,33 +451,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("LicensingMetadata", (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") diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.cs new file mode 100644 index 0000000000..549cf8bee9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20260107065436_InitialCreate.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MessageBodies"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + 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), + BodySize = table.Column(type: "int", nullable: false), + ContentType = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + 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"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs index 8418b8a924..c3d40b7b68 100644 --- a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -248,9 +248,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.Property("Body") - .HasColumnType("longblob"); - b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("varchar(200)"); @@ -451,33 +448,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("LicensingMetadata", (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") diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs deleted file mode 100644 index 0b1076249c..0000000000 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.cs +++ /dev/null @@ -1,574 +0,0 @@ -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: "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 - { - 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_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) - }, - 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: "jsonb", 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: "jsonb", 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: "jsonb", nullable: false), - failure_groups_json = table.Column(type: "jsonb", nullable: false), - headers_json = table.Column(type: "jsonb", nullable: false), - body = table.Column(type: "bytea", nullable: true), - query = table.Column(type: "text", nullable: true), - 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) - }, - 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: "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 - { - 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: "jsonb", 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: "jsonb", 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: "jsonb", 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: "jsonb", nullable: true), - unacknowledged_operations_json = table.Column(type: "jsonb", 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: "jsonb", nullable: false) - }, - constraints: table => - { - 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 - { - 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: "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", - 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_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_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_LicensingMetadata_key", - table: "LicensingMetadata", - column: "key", - unique: true); - - 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); - - migrationBuilder.CreateIndex( - name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", - table: "ThroughputEndpoint", - columns: new[] { "endpoint_name", "throughput_source" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ArchiveOperations"); - - migrationBuilder.DropTable( - name: "CustomChecks"); - - migrationBuilder.DropTable( - name: "DailyThroughput"); - - 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: "LicensingMetadata"); - - 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: "ThroughputEndpoint"); - - migrationBuilder.DropTable( - name: "TrialLicense"); - } - } -} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.Designer.cs similarity index 95% rename from src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.Designer.cs index 25787d809f..b0d8971e23 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260106052900_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations { [DbContext(typeof(PostgreSqlDbContext))] - [Migration("20260106052900_InitialCreate")] + [Migration("20260107065432_InitialCreate")] partial class InitialCreate { /// @@ -299,10 +299,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); - b.Property("Body") - .HasColumnType("bytea") - .HasColumnName("body"); - b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("character varying(200)") @@ -542,39 +538,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("LicensingMetadata", (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") diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.cs new file mode 100644 index 0000000000..41cbc4bf12 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20260107065432_InitialCreate.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MessageBodies"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MessageBodies", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + body = table.Column(type: "bytea", nullable: false), + body_size = table.Column(type: "integer", nullable: false), + content_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + etag = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("p_k_message_bodies", x => x.id); + }); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs index d40937b4fe..74676b3afb 100644 --- a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -296,10 +296,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); - b.Property("Body") - .HasColumnType("bytea") - .HasColumnName("body"); - b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("character varying(200)") @@ -539,39 +535,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("LicensingMetadata", (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") diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs deleted file mode 100644 index dd85b09c5a..0000000000 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.cs +++ /dev/null @@ -1,573 +0,0 @@ -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: "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 - { - 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), - 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) - }, - 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), - HeadersJson = table.Column(type: "nvarchar(max)", nullable: false), - Body = table.Column(type: "varbinary(max)", nullable: true), - Query = table.Column(type: "nvarchar(max)", nullable: true), - 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) - }, - 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: "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 - { - 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: "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 - { - 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: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", - table: "DailyThroughput", - columns: new[] { "EndpointName", "ThroughputSource", "Date" }, - unique: true); - - 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_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_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_LicensingMetadata_Key", - table: "LicensingMetadata", - column: "Key", - unique: true); - - 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); - - migrationBuilder.CreateIndex( - name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", - table: "ThroughputEndpoint", - columns: new[] { "EndpointName", "ThroughputSource" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ArchiveOperations"); - - migrationBuilder.DropTable( - name: "CustomChecks"); - - migrationBuilder.DropTable( - name: "DailyThroughput"); - - 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: "LicensingMetadata"); - - 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: "ThroughputEndpoint"); - - migrationBuilder.DropTable( - name: "TrialLicense"); - } - } -} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.Designer.cs similarity index 95% rename from src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.Designer.cs rename to src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.Designer.cs index 3805bfd902..741d1b84c2 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260106053230_InitialCreate.Designer.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.Sql.SqlServer.Migrations { [DbContext(typeof(SqlServerDbContext))] - [Migration("20260106053230_InitialCreate")] + [Migration("20260107065422_InitialCreate")] partial class InitialCreate { /// @@ -251,9 +251,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("Body") - .HasColumnType("varbinary(max)"); - b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -454,33 +451,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("LicensingMetadata", (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") diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.cs new file mode 100644 index 0000000000..c9d5a33289 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20260107065422_InitialCreate.cs @@ -0,0 +1,37 @@ +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.DropTable( + name: "MessageBodies"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MessageBodies", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Body = table.Column(type: "varbinary(max)", nullable: false), + BodySize = table.Column(type: "int", nullable: false), + ContentType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Etag = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MessageBodies", x => x.Id); + }); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs index 221a15ba62..3b06ba5be9 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -248,9 +248,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("Body") - .HasColumnType("varbinary(max)"); - b.Property("ConversationId") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -451,33 +448,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("LicensingMetadata", (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") diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs index 2cd8812506..6686f5afc9 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs @@ -1,9 +1,9 @@ namespace ServiceControl.Persistence.Sql.SqlServer; -using Core.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ServiceControl.Persistence; class SqlServerDatabaseMigrator : IDatabaseMigrator { diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs index 7972c2a801..714a6d2098 100644 --- a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs @@ -1,5 +1,7 @@ namespace ServiceControl.Persistence.Sql.SqlServer; +using System; +using System.IO; using Configuration; using ServiceControl.Persistence; @@ -7,6 +9,8 @@ public class SqlServerPersistenceConfiguration : IPersistenceConfiguration { const string DatabaseConnectionStringKey = "Database/ConnectionString"; const string CommandTimeoutKey = "Database/CommandTimeout"; + const string MessageBodyStoragePathKey = "MessageBody/StoragePath"; + const string MinBodySizeForCompressionKey = "MessageBody/MinCompressionSize"; public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) { @@ -18,10 +22,18 @@ public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootName $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); } + // Initialize message body storage path + var messageBodyStoragePath = SettingsReader.Read( + settingsRootNamespace, MessageBodyStoragePathKey, Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Particular", "ServiceControl", "bodies")); + var settings = new SqlServerPersisterSettings { ConnectionString = connectionString, CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + MessageBodyStoragePath = messageBodyStoragePath, + MinBodySizeForCompression = SettingsReader.Read(settingsRootNamespace, MinBodySizeForCompressionKey, 4096) }; return settings; diff --git a/src/ServiceControl.Persistence/PersistenceSettings.cs b/src/ServiceControl.Persistence/PersistenceSettings.cs index f72b4694f4..e0715d982b 100644 --- a/src/ServiceControl.Persistence/PersistenceSettings.cs +++ b/src/ServiceControl.Persistence/PersistenceSettings.cs @@ -13,7 +13,18 @@ public abstract class PersistenceSettings public bool EnableFullTextSearchOnBodies { get; set; } = true; - public int MaxBodySizeToStore { get; set; } = 102400; // 100KB default (matches Audit) + /// + /// Base path for storing message bodies on the filesystem. + /// Initialized by persistence configuration based on DatabasePath or explicit configuration. + /// + public string MessageBodyStoragePath { get; set; } + + /// + /// Minimum body size in bytes to trigger compression. Bodies smaller than this threshold + /// will not be compressed to avoid performance overhead on small payloads. + /// Default is 4KB (4096 bytes). + /// + public int MinBodySizeForCompression { get; set; } = 4096; public TimeSpan? OverrideCustomCheckRepeatTime { get; set; } } diff --git a/src/ServiceControl/Hosting/Commands/SetupCommand.cs b/src/ServiceControl/Hosting/Commands/SetupCommand.cs index 12bd81c2be..a80047f649 100644 --- a/src/ServiceControl/Hosting/Commands/SetupCommand.cs +++ b/src/ServiceControl/Hosting/Commands/SetupCommand.cs @@ -1,5 +1,7 @@ namespace ServiceControl.Hosting.Commands { + using System; + using System.IO; using System.Runtime.InteropServices; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -36,9 +38,10 @@ public override async Task Execute(HostArguments args, Settings settings) await host.StartAsync(); + var logger = LoggerUtil.CreateStaticLogger(); if (args.SkipQueueCreation) { - LoggerUtil.CreateStaticLogger().LogInformation("Skipping queue creation"); + logger.LogInformation("Skipping queue creation"); } else { @@ -47,10 +50,25 @@ public override async Task Execute(HostArguments args, Settings settings) var transportCustomization = TransportFactory.Create(transportSettings); await transportCustomization.ProvisionQueues(transportSettings, componentSetupContext.Queues); + } - await host.Services.GetRequiredService().ApplyMigrations(); + // Create message body storage directory if it doesn't exist + var persistenceSettings = host.Services.GetRequiredService(); + if (!string.IsNullOrEmpty(persistenceSettings.MessageBodyStoragePath)) + { + if (!Directory.Exists(persistenceSettings.MessageBodyStoragePath)) + { + logger.LogInformation("Creating message body storage directory: {StoragePath}", persistenceSettings.MessageBodyStoragePath); + Directory.CreateDirectory(persistenceSettings.MessageBodyStoragePath); + } + } + else + { + throw new Exception("Message body storage path is not configured."); } + await host.Services.GetRequiredService().ApplyMigrations(); + await host.StopAsync(); } }