diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 7e49d6d805..f0987c5c92 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -19,6 +19,10 @@
+
+
+
+
@@ -50,6 +54,7 @@
+
@@ -62,6 +67,7 @@
+
@@ -78,6 +84,9 @@
+
+
+
diff --git a/src/ServiceControl.Persistence.Sql.Core/.editorconfig b/src/ServiceControl.Persistence.Sql.Core/.editorconfig
new file mode 100644
index 0000000000..ff993b49bb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/.editorconfig
@@ -0,0 +1,4 @@
+[*.cs]
+
+# Justification: ServiceControl app has no synchronization context
+dotnet_diagnostic.CA2007.severity = none
diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs
new file mode 100644
index 0000000000..39de35cfe4
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs
@@ -0,0 +1,6 @@
+namespace ServiceControl.Persistence.Sql.Core.Abstractions;
+
+public interface IDatabaseMigrator
+{
+ Task ApplyMigrations(CancellationToken cancellationToken = default);
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs
new file mode 100644
index 0000000000..25de65b710
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs
@@ -0,0 +1,10 @@
+namespace ServiceControl.Persistence.Sql.Core.Abstractions;
+
+using ServiceControl.Persistence;
+
+public abstract class SqlPersisterSettings : PersistenceSettings
+{
+ public required string ConnectionString { get; set; }
+ public int CommandTimeout { get; set; } = 30;
+ public bool EnableSensitiveDataLogging { get; set; } = false;
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs
new file mode 100644
index 0000000000..179b88e829
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs
@@ -0,0 +1,33 @@
+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 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 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/DailyThroughputEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs
new file mode 100644
index 0000000000..41f3052a29
--- /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; }
+}
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..4666dd85bf
--- /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; }
+}
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..44f1b2ddcf
--- /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; }
+}
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/DailyThroughputConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs
new file mode 100644
index 0000000000..dd5394f438
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs
@@ -0,0 +1,31 @@
+namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations;
+
+using Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+class DailyThroughputConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("DailyThroughput")
+ .HasIndex(e => new
+ {
+ e.EndpointName,
+ e.ThroughputSource,
+ e.Date
+ }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date")
+ .IsUnique();
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.EndpointName)
+ .IsRequired()
+ .HasMaxLength(200);
+ builder.Property(e => e.ThroughputSource)
+ .IsRequired()
+ .HasMaxLength(50);
+ builder.Property(e => e.Date)
+ .IsRequired();
+ builder.Property(e => e.MessageCount)
+ .IsRequired();
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs
new file mode 100644
index 0000000000..06e2318cbd
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs
@@ -0,0 +1,22 @@
+namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations;
+
+using Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+class LicensingMetadataEntityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("LicensingMetadata")
+ .HasIndex(e => e.Key)
+ .IsUnique();
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Key)
+ .IsRequired()
+ .HasMaxLength(200);
+ builder.Property(e => e.Data)
+ .IsRequired()
+ .HasMaxLength(2000);
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs
new file mode 100644
index 0000000000..dbd1e630ee
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs
@@ -0,0 +1,32 @@
+namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations;
+
+using Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+class ThroughputEndpointConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("ThroughputEndpoint")
+ .HasIndex(e => new
+ {
+ e.EndpointName,
+ e.ThroughputSource
+ }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource")
+ .IsUnique();
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.EndpointName)
+ .IsRequired()
+ .HasMaxLength(200);
+ builder.Property(e => e.ThroughputSource)
+ .IsRequired()
+ .HasMaxLength(50);
+
+ builder.Property(e => e.SanitizedEndpointName);
+ builder.Property(e => e.EndpointIndicators);
+ builder.Property(e => e.UserIndicator);
+ builder.Property(e => e.Scope);
+ builder.Property(e => e.LastCollectedData);
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs
new file mode 100644
index 0000000000..a00d3277c0
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs
@@ -0,0 +1,23 @@
+namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations;
+
+using Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+class TrialLicenseConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("TrialLicense");
+
+ builder.HasKey(e => e.Id);
+
+ // Ensure only one row exists by using a fixed primary key
+ builder.Property(e => e.Id)
+ .HasDefaultValue(1)
+ .ValueGeneratedNever();
+
+ builder.Property(e => e.TrialEndDate)
+ .IsRequired();
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs
new file mode 100644
index 0000000000..89ff1bbeae
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs
@@ -0,0 +1,321 @@
+namespace ServiceControl.Persistence.Sql.Core.Implementation;
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Particular.LicensingComponent.Contracts;
+using Particular.LicensingComponent.Persistence;
+using ServiceControl.Persistence.Sql.Core.DbContexts;
+using ServiceControl.Persistence.Sql.Core.Entities;
+
+public class LicensingDataStore(IServiceProvider serviceProvider) : ILicensingDataStore
+{
+ #region Throughput
+ static DateOnly DefaultCutOff()
+ => DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-400));
+
+ public async Task>> GetEndpointThroughputByQueueName(IList queueNames, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ 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 result;
+ }
+
+ public async Task IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+ var cutoffDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days + 1));
+ return await dbContext.Throughput.AnyAsync(t => t.Date >= cutoffDate, cancellationToken);
+ }
+
+ public async Task IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+ 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 async Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSource, IList throughput, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ 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 async Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ 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 async Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ 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 async Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+ 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 async Task SaveEndpoint(Endpoint endpoint, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+ 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 async Task UpdateUserIndicatorOnEndpoints(List userIndicatorUpdates, CancellationToken cancellationToken)
+ {
+ var updates = userIndicatorUpdates.ToDictionary(u => u.Name, u => u.UserIndicator);
+ using var scope = serviceProvider.CreateScope();
+ using var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ // 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
+ async Task LoadMetadata(string key, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ await using var dbContext = scope.ServiceProvider.GetRequiredService();
+ var existing = await dbContext.LicensingMetadata
+ .AsNoTracking()
+ .SingleOrDefaultAsync(m => m.Key == key, cancellationToken);
+ if (existing is null)
+ {
+ return default;
+ }
+ return JsonSerializer.Deserialize(existing.Data);
+
+ }
+
+ async Task SaveMetadata(string key, T data, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ await using var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ var existing = await dbContext.LicensingMetadata.SingleOrDefaultAsync(m => m.Key == key, cancellationToken);
+
+ var serialized = JsonSerializer.Serialize(data);
+
+ if (existing is null)
+ {
+ LicensingMetadataEntity newMetadata = new()
+ {
+ Key = key,
+ Data = serialized
+ };
+ _ = await dbContext.LicensingMetadata.AddAsync(newMetadata, cancellationToken);
+ }
+ else
+ {
+ existing.Data = serialized;
+ _ = dbContext.LicensingMetadata.Update(existing);
+ }
+
+ _ = await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ #endregion
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs
new file mode 100644
index 0000000000..80cdce3eae
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs
@@ -0,0 +1,56 @@
+namespace ServiceControl.Persistence.Sql.Core.Implementation;
+
+using DbContexts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using ServiceControl.Persistence;
+
+public class TrialLicenseDataProvider : ITrialLicenseDataProvider
+{
+ readonly IServiceProvider serviceProvider;
+ const int SingletonId = 1;
+
+ public TrialLicenseDataProvider(IServiceProvider serviceProvider)
+ {
+ this.serviceProvider = serviceProvider;
+ }
+
+ public async Task GetTrialEndDate(CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ var entity = await dbContext.TrialLicenses
+ .AsNoTracking()
+ .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken);
+
+ return entity?.TrialEndDate;
+ }
+
+ public async Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken)
+ {
+ using var scope = serviceProvider.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ var existingEntity = await dbContext.TrialLicenses
+ .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken);
+
+ if (existingEntity != null)
+ {
+ // Update existing
+ existingEntity.TrialEndDate = trialEndDate;
+ }
+ else
+ {
+ // Insert new
+ var newEntity = new Entities.TrialLicenseEntity
+ {
+ Id = SingletonId,
+ TrialEndDate = trialEndDate
+ };
+ await dbContext.TrialLicenses.AddAsync(newEntity, cancellationToken);
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj
new file mode 100644
index 0000000000..e839730594
--- /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..ff993b49bb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig
@@ -0,0 +1,4 @@
+[*.cs]
+
+# Justification: ServiceControl app has no synchronization context
+dotnet_diagnostic.CA2007.severity = none
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.Designer.cs
new file mode 100644
index 0000000000..e1129e6700
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.Designer.cs
@@ -0,0 +1,143 @@
+//
+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("20251210040740_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.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.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.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")
+ .HasDefaultValue(1);
+
+ b.Property("TrialEndDate")
+ .HasColumnType("date");
+
+ b.HasKey("Id");
+
+ b.ToTable("TrialLicense", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.cs
new file mode 100644
index 0000000000..c1d7bfa3fc
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.cs
@@ -0,0 +1,128 @@
+#nullable disable
+
+namespace ServiceControl.Persistence.Sql.MySQL.Migrations
+{
+ using System;
+ using Microsoft.EntityFrameworkCore.Metadata;
+ using Microsoft.EntityFrameworkCore.Migrations;
+
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .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: "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: "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, defaultValue: 1),
+ TrialEndDate = table.Column(type: "date", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_TrialLicense", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date",
+ table: "DailyThroughput",
+ columns: new[] { "EndpointName", "ThroughputSource", "Date" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LicensingMetadata_Key",
+ table: "LicensingMetadata",
+ column: "Key",
+ 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: "DailyThroughput");
+
+ migrationBuilder.DropTable(
+ name: "LicensingMetadata");
+
+ 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..df1713a0f5
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs
@@ -0,0 +1,140 @@
+//
+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.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.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.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")
+ .HasDefaultValue(1);
+
+ b.Property("TrialEndDate")
+ .HasColumnType("date");
+
+ b.HasKey("Id");
+
+ b.ToTable("TrialLicense", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs
new file mode 100644
index 0000000000..9a7ab109e8
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs
@@ -0,0 +1,48 @@
+namespace ServiceControl.Persistence.Sql.MySQL;
+
+using Core.Abstractions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+class MySqlDatabaseMigrator : IDatabaseMigrator
+{
+ readonly IServiceProvider serviceProvider;
+ readonly ILogger logger;
+
+ public MySqlDatabaseMigrator(
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ this.serviceProvider = serviceProvider;
+ this.logger = logger;
+ }
+
+ public async Task ApplyMigrations(CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Initializing MySQL database");
+
+ try
+ {
+ using var scope = serviceProvider.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ logger.LogDebug("Testing database connectivity");
+ var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
+ if (!canConnect)
+ {
+ throw new Exception("Cannot connect to MySQL database. Check connection string and ensure database server is accessible.");
+ }
+
+ logger.LogInformation("Applying pending migrations");
+ await dbContext.Database.MigrateAsync(cancellationToken);
+
+ logger.LogInformation("MySQL database initialized successfully");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to initialize MySQL database");
+ throw;
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs
new file mode 100644
index 0000000000..db430f16b8
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs
@@ -0,0 +1,16 @@
+namespace ServiceControl.Persistence.Sql.MySQL;
+
+using Core.DbContexts;
+using Microsoft.EntityFrameworkCore;
+
+class MySqlDbContext : ServiceControlDbContextBase
+{
+ public MySqlDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ protected override void OnModelCreatingProvider(ModelBuilder modelBuilder)
+ {
+ // MySQL-specific configurations if needed
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs
new file mode 100644
index 0000000000..2cccacee55
--- /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 MySQL 8.0 as the server version for migrations
+ optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0)));
+
+ 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..c4207dff30
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs
@@ -0,0 +1,67 @@
+namespace ServiceControl.Persistence.Sql.MySQL;
+
+using Core.Abstractions;
+using Core.DbContexts;
+using Core.Implementation;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Particular.LicensingComponent.Persistence;
+using ServiceControl.Persistence;
+
+class MySqlPersistence : IPersistence
+{
+ readonly MySqlPersisterSettings settings;
+
+ public MySqlPersistence(MySqlPersisterSettings settings)
+ {
+ this.settings = settings;
+ }
+
+ public void AddPersistence(IServiceCollection services)
+ {
+ ConfigureDbContext(services);
+
+ if (settings.MaintenanceMode)
+ {
+ return;
+ }
+
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ public void AddInstaller(IServiceCollection services)
+ {
+ ConfigureDbContext(services);
+
+ services.AddSingleton();
+ }
+
+ void ConfigureDbContext(IServiceCollection services)
+ {
+ services.AddSingleton(settings);
+ services.AddSingleton(settings);
+
+ services.AddDbContext((serviceProvider, options) =>
+ {
+ options.UseMySql(settings.ConnectionString, ServerVersion.AutoDetect(settings.ConnectionString), mySqlOptions =>
+ {
+ mySqlOptions.CommandTimeout(settings.CommandTimeout);
+ if (settings.EnableRetryOnFailure)
+ {
+ mySqlOptions.EnableRetryOnFailure(
+ maxRetryCount: 5,
+ maxRetryDelay: TimeSpan.FromSeconds(30),
+ errorNumbersToAdd: null);
+ }
+ });
+
+ if (settings.EnableSensitiveDataLogging)
+ {
+ options.EnableSensitiveDataLogging();
+ }
+ }, ServiceLifetime.Scoped);
+
+ services.AddScoped(sp => sp.GetRequiredService());
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs
new file mode 100644
index 0000000000..7cd3303d3f
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs
@@ -0,0 +1,35 @@
+namespace ServiceControl.Persistence.Sql.MySQL;
+
+using Configuration;
+using ServiceControl.Persistence;
+
+public class MySqlPersistenceConfiguration : IPersistenceConfiguration
+{
+ const string DatabaseConnectionStringKey = "Database/ConnectionString";
+ const string CommandTimeoutKey = "Database/CommandTimeout";
+
+ public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace)
+ {
+ var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey);
+
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new Exception($"Setting {DatabaseConnectionStringKey} is required for MySQL persistence. " +
+ $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING");
+ }
+
+ var settings = new MySqlPersisterSettings
+ {
+ ConnectionString = connectionString,
+ CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30),
+ };
+
+ return settings;
+ }
+
+ public IPersistence Create(PersistenceSettings settings)
+ {
+ var specificSettings = (MySqlPersisterSettings)settings;
+ return new MySqlPersistence(specificSettings);
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs
new file mode 100644
index 0000000000..b69f72d2e4
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs
@@ -0,0 +1,8 @@
+namespace ServiceControl.Persistence.Sql.MySQL;
+
+using Core.Abstractions;
+
+public class MySqlPersisterSettings : SqlPersisterSettings
+{
+ public bool EnableRetryOnFailure { get; set; } = true;
+}
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj
new file mode 100644
index 0000000000..d5ea500c83
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest b/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest
new file mode 100644
index 0000000000..db462a730c
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest
@@ -0,0 +1,13 @@
+{
+ "Name": "MySQL",
+ "DisplayName": "MySQL",
+ "Description": "MySQL ServiceControl persister",
+ "AssemblyName": "ServiceControl.Persistence.Sql.MySQL",
+ "TypeName": "ServiceControl.Persistence.Sql.MySQL.MySqlPersistenceConfiguration, ServiceControl.Persistence.Sql.MySQL",
+ "Settings": [
+ {
+ "Name": "ServiceControl/Database/ConnectionString",
+ "Mandatory": true
+ }
+ ]
+}
diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig
new file mode 100644
index 0000000000..ff993b49bb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig
@@ -0,0 +1,4 @@
+[*.cs]
+
+# Justification: ServiceControl app has no synchronization context
+dotnet_diagnostic.CA2007.severity = none
diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.Designer.cs
new file mode 100644
index 0000000000..5bfef70c40
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.Designer.cs
@@ -0,0 +1,165 @@
+//
+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("20251210071033_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.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.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.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")
+ .HasDefaultValue(1)
+ .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/20251210071033_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.cs
new file mode 100644
index 0000000000..dd5271dda5
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.cs
@@ -0,0 +1,111 @@
+#nullable disable
+
+namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations
+{
+ using System;
+ using Microsoft.EntityFrameworkCore.Migrations;
+ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ 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: "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: "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, defaultValue: 1),
+ trial_end_date = table.Column(type: "date", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("p_k_trial_licenses", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date",
+ table: "DailyThroughput",
+ columns: new[] { "endpoint_name", "throughput_source", "date" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LicensingMetadata_key",
+ table: "LicensingMetadata",
+ column: "key",
+ 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: "DailyThroughput");
+
+ migrationBuilder.DropTable(
+ name: "LicensingMetadata");
+
+ 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..ff49b46e96
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs
@@ -0,0 +1,162 @@
+//
+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.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.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.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")
+ .HasDefaultValue(1)
+ .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..3d435d487b
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs
@@ -0,0 +1,67 @@
+namespace ServiceControl.Persistence.Sql.PostgreSQL;
+
+using Core.DbContexts;
+using Microsoft.EntityFrameworkCore;
+
+class PostgreSqlDbContext : ServiceControlDbContextBase
+{
+ public PostgreSqlDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ // Apply snake_case naming convention for PostgreSQL
+ foreach (var entity in modelBuilder.Model.GetEntityTypes())
+ {
+ entity.SetTableName(ToSnakeCase(entity.GetTableName()));
+
+ foreach (var property in entity.GetProperties())
+ {
+ property.SetColumnName(ToSnakeCase(property.GetColumnName()));
+ }
+
+ foreach (var key in entity.GetKeys())
+ {
+ key.SetName(ToSnakeCase(key.GetName()));
+ }
+
+ foreach (var foreignKey in entity.GetForeignKeys())
+ {
+ foreignKey.SetConstraintName(ToSnakeCase(foreignKey.GetConstraintName()));
+ }
+
+ foreach (var index in entity.GetIndexes())
+ {
+ index.SetDatabaseName(ToSnakeCase(index.GetDatabaseName()));
+ }
+ }
+
+ base.OnModelCreating(modelBuilder);
+ }
+
+ static string? ToSnakeCase(string? name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return name;
+ }
+
+ var builder = new System.Text.StringBuilder();
+ for (var i = 0; i < name.Length; i++)
+ {
+ var c = name[i];
+ if (char.IsUpper(c) && i > 0)
+ {
+ builder.Append('_');
+ }
+ builder.Append(char.ToLowerInvariant(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..72fa7d731e
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs
@@ -0,0 +1,67 @@
+namespace ServiceControl.Persistence.Sql.PostgreSQL;
+
+using Core.Abstractions;
+using Core.DbContexts;
+using Core.Implementation;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Particular.LicensingComponent.Persistence;
+using ServiceControl.Persistence;
+
+class PostgreSqlPersistence : IPersistence
+{
+ readonly PostgreSqlPersisterSettings settings;
+
+ public PostgreSqlPersistence(PostgreSqlPersisterSettings settings)
+ {
+ this.settings = settings;
+ }
+
+ public void AddPersistence(IServiceCollection services)
+ {
+ ConfigureDbContext(services);
+
+ if (settings.MaintenanceMode)
+ {
+ return;
+ }
+
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ public void AddInstaller(IServiceCollection services)
+ {
+ ConfigureDbContext(services);
+
+ services.AddSingleton();
+ }
+
+ void ConfigureDbContext(IServiceCollection services)
+ {
+ services.AddSingleton(settings);
+ services.AddSingleton(settings);
+
+ services.AddDbContext((serviceProvider, options) =>
+ {
+ options.UseNpgsql(settings.ConnectionString, npgsqlOptions =>
+ {
+ npgsqlOptions.CommandTimeout(settings.CommandTimeout);
+ if (settings.EnableRetryOnFailure)
+ {
+ npgsqlOptions.EnableRetryOnFailure(
+ maxRetryCount: 5,
+ maxRetryDelay: TimeSpan.FromSeconds(30),
+ errorCodesToAdd: null);
+ }
+ });
+
+ if (settings.EnableSensitiveDataLogging)
+ {
+ options.EnableSensitiveDataLogging();
+ }
+ }, ServiceLifetime.Scoped);
+
+ services.AddScoped(sp => sp.GetRequiredService());
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs
new file mode 100644
index 0000000000..99b81c0406
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs
@@ -0,0 +1,35 @@
+namespace ServiceControl.Persistence.Sql.PostgreSQL;
+
+using Configuration;
+using ServiceControl.Persistence;
+
+public class PostgreSqlPersistenceConfiguration : IPersistenceConfiguration
+{
+ const string DatabaseConnectionStringKey = "Database/ConnectionString";
+ const string CommandTimeoutKey = "Database/CommandTimeout";
+
+ public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace)
+ {
+ var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey);
+
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new Exception($"Setting {DatabaseConnectionStringKey} is required for PostgreSQL persistence. " +
+ $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING");
+ }
+
+ var settings = new PostgreSqlPersisterSettings
+ {
+ ConnectionString = connectionString,
+ CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30),
+ };
+
+ return settings;
+ }
+
+ public IPersistence Create(PersistenceSettings settings)
+ {
+ var specificSettings = (PostgreSqlPersisterSettings)settings;
+ return new PostgreSqlPersistence(specificSettings);
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs
new file mode 100644
index 0000000000..c0352984c6
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs
@@ -0,0 +1,8 @@
+namespace ServiceControl.Persistence.Sql.PostgreSQL;
+
+using Core.Abstractions;
+
+public class PostgreSqlPersisterSettings : SqlPersisterSettings
+{
+ public bool EnableRetryOnFailure { get; set; } = true;
+}
diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj
new file mode 100644
index 0000000000..275e2387eb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest b/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest
new file mode 100644
index 0000000000..e04bb32751
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest
@@ -0,0 +1,13 @@
+{
+ "Name": "PostgreSQL",
+ "DisplayName": "PostgreSQL",
+ "Description": "PostgreSQL ServiceControl persister",
+ "AssemblyName": "ServiceControl.Persistence.Sql.PostgreSQL",
+ "TypeName": "ServiceControl.Persistence.Sql.PostgreSQL.PostgreSqlPersistenceConfiguration, ServiceControl.Persistence.Sql.PostgreSQL",
+ "Settings": [
+ {
+ "Name": "ServiceControl/Database/ConnectionString",
+ "Mandatory": true
+ }
+ ]
+}
diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig
new file mode 100644
index 0000000000..ff993b49bb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig
@@ -0,0 +1,4 @@
+[*.cs]
+
+# Justification: ServiceControl app has no synchronization context
+dotnet_diagnostic.CA2007.severity = none
diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.Designer.cs
new file mode 100644
index 0000000000..92dd16614b
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.Designer.cs
@@ -0,0 +1,143 @@
+//
+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("20251210040736_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.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.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.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")
+ .HasDefaultValue(1);
+
+ b.Property("TrialEndDate")
+ .HasColumnType("date");
+
+ b.HasKey("Id");
+
+ b.ToTable("TrialLicense", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.cs
new file mode 100644
index 0000000000..b32f1f2962
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.cs
@@ -0,0 +1,110 @@
+#nullable disable
+
+namespace ServiceControl.Persistence.Sql.SqlServer.Migrations
+{
+ using System;
+ using Microsoft.EntityFrameworkCore.Migrations;
+
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ 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: "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: "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, defaultValue: 1),
+ TrialEndDate = table.Column(type: "date", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_TrialLicense", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date",
+ table: "DailyThroughput",
+ columns: new[] { "EndpointName", "ThroughputSource", "Date" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LicensingMetadata_Key",
+ table: "LicensingMetadata",
+ column: "Key",
+ 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: "DailyThroughput");
+
+ migrationBuilder.DropTable(
+ name: "LicensingMetadata");
+
+ 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..576cf28f6d
--- /dev/null
+++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs
@@ -0,0 +1,140 @@
+//
+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.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.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.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