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/ProjectReferences.Persisters.Primary.props b/src/ProjectReferences.Persisters.Primary.props index 255b45ed5c..c0dfc95209 100644 --- a/src/ProjectReferences.Persisters.Primary.props +++ b/src/ProjectReferences.Persisters.Primary.props @@ -2,6 +2,9 @@ + + + \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Sql.Core/.editorconfig b/src/ServiceControl.Persistence.Sql.Core/.editorconfig new file mode 100644 index 0000000000..bedef15fb6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/.editorconfig @@ -0,0 +1,9 @@ +[*.cs] + +# 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/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs new file mode 100644 index 0000000000..3329c92b36 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/BasePersistence.cs @@ -0,0 +1,43 @@ +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; +using Particular.LicensingComponent.Persistence; + +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(); + services.AddSingleton(); + } +} 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..c37f8a9976 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -0,0 +1,69 @@ +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; } + 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; } + public DbSet LicensingMetadata { get; set; } + public DbSet Endpoints { get; set; } + public DbSet Throughput { 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()); + modelBuilder.ApplyConfiguration(new LicensingMetadataEntityConfiguration()); + modelBuilder.ApplyConfiguration(new ThroughputEndpointConfiguration()); + modelBuilder.ApplyConfiguration(new DailyThroughputConfiguration()); + + OnModelCreatingProvider(modelBuilder); + } + + protected virtual void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + } +} 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/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/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..fcc815159f --- /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? 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/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..4e161dad1c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/FailedMessageEntity.cs @@ -0,0 +1,33 @@ +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!; + public string HeadersJson { 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; } +} 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/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/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/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/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/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/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/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..a5ddc6575b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/EventLogItemConfiguration.cs @@ -0,0 +1,40 @@ +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.RelatedToJson) + .HasColumnType("jsonb") + .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..af17a802b1 --- /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).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 new file mode 100644 index 0000000000..e969222072 --- /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).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 new file mode 100644 index 0000000000..f0a6ef63fe --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/FailedMessageConfiguration.cs @@ -0,0 +1,66 @@ +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).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); + + // 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); + } +} 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/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/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..bff5840465 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/MessageRedirectsConfiguration.cs @@ -0,0 +1,29 @@ +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) + .HasColumnType("jsonb") + .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..bf4f6a24fb --- /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).HasColumnType("jsonb").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..8c7ee6185d --- /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).HasColumnType("jsonb").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..e7104f8e8b --- /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).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 new file mode 100644 index 0000000000..349c30a5b7 --- /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).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/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/EntityConfigurations/TrialLicenseConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs new file mode 100644 index 0000000000..903892ba1b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs @@ -0,0 +1,22 @@ +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) + .ValueGeneratedNever(); + + builder.Property(e => e.TrialEndDate) + .IsRequired(); + } +} 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/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/Implementation/EditFailedMessagesManager.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs new file mode 100644 index 0000000000..106b01f40a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EditFailedMessagesManager.cs @@ -0,0 +1,126 @@ +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 Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.MessageFailures; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +class EditFailedMessagesManager( + IServiceScope scope) : IEditFailedMessagesManager +{ + readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); + string? currentEditingRequestId; + FailedMessage? currentMessage; + + 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, JsonSerializationOptions.Default) ?? []; + var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; + + 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) + { + 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)); + + if (entity != null) + { + entity.Status = failedMessage.Status; + 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, JsonSerializationOptions.Default); + var messageType = GetMetadata(lastAttempt, "MessageType"); + var sendingEndpoint = GetMetadata(lastAttempt, "SendingEndpoint"); + var receivingEndpoint = GetMetadata(lastAttempt, "ReceivingEndpoint"); + + entity.MessageId = lastAttempt.MessageId; + entity.MessageType = messageType; + entity.TimeSent = lastAttempt.AttemptedAt; + 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; + } + + 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..4f9ef498be --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/EndpointSettingsStore.cs @@ -0,0 +1,69 @@ +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 => + { + // Use EF's change tracking for upsert + 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 + { + existing.TrackInstances = settings.TrackInstances; + } + + 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..3593409e8c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.FailureGroups.cs @@ -0,0 +1,240 @@ +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.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, JsonSerializationOptions.Default) ?? []; + // 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, JsonSerializationOptions.Default) ?? []; + 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, JsonSerializationOptions.Default) ?? []; + 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, JsonSerializationOptions.Default) ?? []; + 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 id = Guid.Parse(groupId); + + // Use EF's change tracking for upsert + 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 + { + existing.Comment = comment; + } + + 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..620cb49a2f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.MessageQueries.cs @@ -0,0 +1,423 @@ +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 Infrastructure; +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, JsonSerializationOptions.Default) ?? []; + 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, JsonSerializationOptions.Default) ?? [], + FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? [] + }; + }); + } +} 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..ff0d298b5a --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.ViewMapping.cs @@ -0,0 +1,165 @@ +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 Infrastructure; +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), + "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, JsonSerializationOptions.Default) ?? []; + 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(), JsonSerializationOptions.Default); + } + + if (lastAttempt.MessageMetadata.TryGetValue("ReceivingEndpoint", out var receivingObj) && receivingObj is JsonElement receivingJson) + { + receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText(), JsonSerializationOptions.Default); + } + } + + 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, JsonSerializationOptions.Default) ?? []; + var lastAttempt = processingAttempts.LastOrDefault(); + var headers = JsonSerializer.Deserialize>(entity.HeadersJson, JsonSerializationOptions.Default) ?? []; + + // 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 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(), JsonSerializationOptions.Default); + } + + if (metadata.TryGetValue("ReceivingEndpoint", out var receivingObj) && receivingObj is JsonElement receivingJson) + { + receivingEndpoint = JsonSerializer.Deserialize(receivingJson.GetRawText(), JsonSerializationOptions.Default); + } + + if (metadata.TryGetValue("OriginatesFromSaga", out var sagaObj) && sagaObj is JsonElement sagaJson) + { + originatesFromSaga = JsonSerializer.Deserialize(sagaJson.GetRawText(), JsonSerializationOptions.Default); + } + + if (metadata.TryGetValue("InvokedSagas", out var sagasObj) && sagasObj is JsonElement sagasJson) + { + invokedSagas = JsonSerializer.Deserialize>(sagasJson.GetRawText(), JsonSerializationOptions.Default); + } + } + + // 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, + IsSystemMessage = isSystemMessage, + ConversationId = entity.ConversationId, + Headers = 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..2c0544db61 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ErrorMessageDataStore.cs @@ -0,0 +1,129 @@ +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 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, JsonSerializationOptions.Default) ?? [], + FailureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? [] + }).ToArray(); + }); + } + + public Task StoreFailedErrorImport(FailedErrorImport failure) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = new FailedErrorImportEntity + { + Id = Guid.Parse(failure.Id), + MessageJson = JsonSerializer.Serialize(failure.Message, JsonSerializationOptions.Default), + 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, 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 + 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"), + }; + + 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..49dc6448f3 --- /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, + RelatedToJson = logItem.RelatedTo != null ? JsonSerializer.Serialize(logItem.RelatedTo, JsonSerializationOptions.Default) : 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.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 + 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..7a243cc929 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ExternalIntegrationRequestsDataStore.cs @@ -0,0 +1,150 @@ +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 Infrastructure; +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(); + + 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, JsonSerializationOptions.Default), + 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, JsonSerializationOptions.Default)!) + .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..9332b8e8df --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/FailedErrorImportDataStore.cs @@ -0,0 +1,82 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Diagnostics; +using Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ServiceControl.Operations; +using ServiceControl.Persistence; + +public class FailedErrorImportDataStore : DataStoreBase, IFailedErrorImportDataStore +{ + readonly ILogger logger; + + 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, JsonSerializationOptions.Default); + + 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..fe41256002 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/GroupsDataStore.cs @@ -0,0 +1,129 @@ +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.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Recoverability; + +public class GroupsDataStore : DataStoreBase, IGroupsDataStore +{ + 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, JsonSerializationOptions.Default) ?? []; + 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, JsonSerializationOptions.Default) ?? [] + }; + }); + } +} 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..1551732d85 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs @@ -0,0 +1,328 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Infrastructure; +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, JsonSerializationOptions.Default); + }); + } + + 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, JsonSerializationOptions.Default); + + 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/Implementation/MessageRedirectsDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs new file mode 100644 index 0000000000..36ec2dd8c9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/MessageRedirectsDataStore.cs @@ -0,0 +1,83 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Entities; +using Infrastructure; +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, JsonSerializationOptions.Default) ?? []; + + 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, JsonSerializationOptions.Default); + var newETag = Guid.NewGuid().ToString(); + var newLastModified = DateTime.UtcNow; + + var id = Guid.Parse(MessageRedirectsCollection.DefaultId); + + // Use EF's change tracking for upsert + 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 + { + existing.ETag = newETag; + existing.LastModified = newLastModified; + existing.RedirectsJson = redirectsJson; + } + + 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..73a2a56afe --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/NotificationsManager.cs @@ -0,0 +1,59 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using DbContexts; +using Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Notifications; +using ServiceControl.Persistence; + +class NotificationsManager(IServiceScope scope) : INotificationsManager +{ + readonly ServiceControlDbContextBase dbContext = scope.ServiceProvider.GetRequiredService(); + + 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, JsonSerializationOptions.Default) ?? 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..8952d8df8c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryBatchesManager.cs @@ -0,0 +1,252 @@ +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 Infrastructure; +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 = []; + + 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, JsonSerializationOptions.Default) ?? []; + 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, JsonSerializationOptions.Default) + ?? 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, JsonSerializationOptions.Default) ?? [] + }; + } + + 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, JsonSerializationOptions.Default) ?? []; + var failureGroups = JsonSerializer.Deserialize>(entity.FailureGroupsJson, JsonSerializationOptions.Default) ?? []; + + 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..646b55cfc0 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryDocumentDataStore.cs @@ -0,0 +1,322 @@ +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 Microsoft.Extensions.Logging; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +public class RetryDocumentDataStore : DataStoreBase, IRetryDocumentDataStore +{ + readonly ILogger logger; + + 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, JsonSerializationOptions.Default), + 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, JsonSerializationOptions.Default) ?? [] + }).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, JsonSerializationOptions.Default) ?? []; + 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, JsonSerializationOptions.Default) ?? []; + 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..05171b740e --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/RetryHistoryDataStore.cs @@ -0,0 +1,147 @@ +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.Persistence; +using ServiceControl.Recoverability; + +public class RetryHistoryDataStore : DataStoreBase, IRetryHistoryDataStore +{ + const int SingletonId = 1; + + 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, JsonSerializationOptions.Default) ?? []; + + var unacknowledgedOperations = string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson) + ? [] + : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonSerializationOptions.Default) ?? []; + + 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, JsonSerializationOptions.Default) ?? []; + + var unacknowledgedOperations = string.IsNullOrEmpty(entity.UnacknowledgedOperationsJson) + ? [] + : JsonSerializer.Deserialize>(entity.UnacknowledgedOperationsJson, JsonSerializationOptions.Default) ?? []; + + // 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, JsonSerializationOptions.Default); + entity.UnacknowledgedOperationsJson = JsonSerializer.Serialize(unacknowledgedOperations, JsonSerializationOptions.Default); + + 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, JsonSerializationOptions.Default) ?? []; + + // 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, JsonSerializationOptions.Default); + 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..732b3419b5 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/ServiceControlSubscriptionStorage.cs @@ -0,0 +1,229 @@ +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 Infrastructure; +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 }, JsonSerializationOptions.Default) + }; + await dbContext.Subscriptions.AddAsync(subscription, cancellationToken); + } + else + { + var subscribers = JsonSerializer.Deserialize>(subscription.SubscribersJson, JsonSerializationOptions.Default) ?? []; + if (!subscribers.Contains(subscriptionClient)) + { + subscribers.Add(subscriptionClient); + subscription.SubscribersJson = JsonSerializer.Serialize(subscribers, JsonSerializationOptions.Default); + } + 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, JsonSerializationOptions.Default) ?? []; + + if (subscribers.Remove(subscriptionClient)) + { + subscription.SubscribersJson = JsonSerializer.Serialize(subscribers, JsonSerializationOptions.Default); + 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, JsonSerializationOptions.Default) ?? [] + 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(); + } +} 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..2157a991fc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs @@ -0,0 +1,50 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using Entities; +using Microsoft.EntityFrameworkCore; +using ServiceControl.Persistence; + +public class TrialLicenseDataProvider : DataStoreBase, ITrialLicenseDataProvider +{ + const int SingletonId = 1; + + public TrialLicenseDataProvider(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + public Task GetTrialEndDate(CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + var entity = await dbContext.TrialLicenses + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); + + return entity?.TrialEndDate; + }); + } + + public Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken) + { + return ExecuteWithDbContext(async dbContext => + { + // 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 + { + existing.TrialEndDate = trialEndDate; + } + + await dbContext.SaveChangesAsync(cancellationToken); + }); + } +} 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..0b3c730fc9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/UnitOfWork/RecoverabilityIngestionUnitOfWork.cs @@ -0,0 +1,189 @@ +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; +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) + { + 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, MediaTypeNames.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"); + + + // Extract denormalized fields from headers for efficient querying + 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 + .FirstOrDefaultAsync(fm => fm.UniqueMessageId == uniqueMessageId); + + List attempts; + if (existingMessage != null) + { + // Merge with existing attempts + attempts = JsonSerializer.Deserialize>(existingMessage.ProcessingAttemptsJson, JsonSerializationOptions.Default) ?? []; + + // 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)]; + + // Update the tracked entity + existingMessage.Status = FailedMessageStatus.Unresolved; + 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; + 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; + } + 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, 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, + 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, + }; + 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; +} 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.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); + } +} 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..64217d6044 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj @@ -0,0 +1,20 @@ + + + + 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..fc68ac3228 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig @@ -0,0 +1,9 @@ +[*.cs] + +# 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.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs new file mode 100644 index 0000000000..1d689bf060 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.Designer.cs @@ -0,0 +1,710 @@ +// +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("20251216015935_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.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("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("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/20251216015935_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs new file mode 100644 index 0000000000..16c6ba2775 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251216015935_InitialCreate.cs @@ -0,0 +1,661 @@ +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"), + 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/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs new file mode 100644 index 0000000000..15a01b6c39 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -0,0 +1,707 @@ +// +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 + +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.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("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("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/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..e2eb64d47d --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs @@ -0,0 +1,27 @@ +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 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.MySQL/MySqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs new file mode 100644 index 0000000000..d5215fa7f9 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs @@ -0,0 +1,20 @@ +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"; + // 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); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs new file mode 100644 index 0000000000..e34f650f09 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs @@ -0,0 +1,58 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +class MySqlPersistence : BasePersistence, IPersistence +{ + readonly MySqlPersisterSettings settings; + + public MySqlPersistence(MySqlPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + RegisterDataStores(services, settings.MaintenanceMode); + } + + 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..fc68ac3228 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig @@ -0,0 +1,9 @@ +[*.cs] + +# 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/Migrations/20251216015817_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs new file mode 100644 index 0000000000..39b41cad63 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.Designer.cs @@ -0,0 +1,850 @@ +// +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 new file mode 100644 index 0000000000..e272358e38 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251216015817_InitialCreate.cs @@ -0,0 +1,572 @@ +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 new file mode 100644 index 0000000000..d68da971bc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -0,0 +1,847 @@ +// +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/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..46d736d08c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs @@ -0,0 +1,86 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using System.Text; +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class PostgreSqlDbContext : ServiceControlDbContextBase +{ + public PostgreSqlDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Apply snake_case naming convention for PostgreSQL + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + var tableName = entity.GetTableName(); + if (tableName != null) + { + entity.SetTableName(ToSnakeCase(tableName)); + } + + foreach (var property in entity.GetProperties()) + { + 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/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..4ee76b9981 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -0,0 +1,59 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +class PostgreSqlPersistence : BasePersistence, IPersistence +{ + readonly PostgreSqlPersisterSettings settings; + + public PostgreSqlPersistence(PostgreSqlPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + RegisterDataStores(services, settings.MaintenanceMode); + } + + 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..fc68ac3228 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig @@ -0,0 +1,9 @@ +[*.cs] + +# 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.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs new file mode 100644 index 0000000000..f954d12a29 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.Designer.cs @@ -0,0 +1,710 @@ +// +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("20251216020009_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.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("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("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/20251216020009_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs new file mode 100644 index 0000000000..196eb3ac47 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251216020009_InitialCreate.cs @@ -0,0 +1,571 @@ +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), + 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/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs new file mode 100644 index 0000000000..d108aef281 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -0,0 +1,707 @@ +// +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 + +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.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("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("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/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..66946e322b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs @@ -0,0 +1,27 @@ +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 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)"); + } + } + } + } +} 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..d9665cb898 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +class SqlServerPersistence : BasePersistence, IPersistence +{ + readonly SqlServerPersisterSettings settings; + + public SqlServerPersistence(SqlServerPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + RegisterDataStores(services, settings.MaintenanceMode); + } + + 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.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..cd556daae8 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpCustomChecksDataStore.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.Persistence.Sql; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.Contracts.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..18e0a27963 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql/NoOpServiceControlSubscriptionStorage.cs @@ -0,0 +1,24 @@ +namespace ServiceControl.Persistence.Sql; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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.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..45f21ca2e9 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.Persistence.Tests; + +using System.Threading.Tasks; +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 fa8d9a30e6..6301b9f260 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,20 @@ 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.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 @@ -1025,6 +1040,90 @@ 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 + {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 @@ -1110,6 +1209,13 @@ 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} + {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}