diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260801000409_AddGroupComments.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260801000409_AddGroupComments.Designer.cs
new file mode 100644
index 0000000000..93d607c977
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260801000409_AddGroupComments.Designer.cs
@@ -0,0 +1,445 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using ServiceControl.Persistence.EFCore.PostgreSql;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ [DbContext(typeof(PostgreSqlServiceControlDbContext))]
+ [Migration("20260801000409_AddGroupComments")]
+ partial class AddGroupComments
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b =>
+ {
+ b.Property("Name")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("name");
+
+ b.Property("TrackInstances")
+ .HasColumnType("boolean")
+ .HasColumnName("track_instances");
+
+ b.HasKey("Name")
+ .HasName("pk_endpoint_settings");
+
+ b.ToTable("endpoint_settings", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EventLogItemEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("category");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("event_type");
+
+ b.Property("RaisedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("raised_at");
+
+ b.PrimitiveCollection>("RelatedTo")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("related_to");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasColumnName("severity");
+
+ b.HasKey("Id")
+ .HasName("pk_event_log_items");
+
+ b.HasIndex("RaisedAt", "Id")
+ .IsDescending()
+ .HasDatabaseName("ix_event_log_items_raised_at_id");
+
+ b.ToTable("EventLogItems", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b =>
+ {
+ b.Property("UniqueMessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("unique_message_id");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("body");
+
+ b.Property("BodyStoredExternally")
+ .HasColumnType("boolean")
+ .HasColumnName("body_stored_externally");
+
+ b.Property("ExceptionInfo")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("exception_info");
+
+ b.Property("FailedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("failed_at");
+
+ b.Property("HeadersJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("headers_json");
+
+ b.Property("MessageId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("message_id");
+
+ b.HasKey("UniqueMessageId")
+ .HasName("pk_failed_error_imports");
+
+ b.HasIndex("FailedAt")
+ .HasDatabaseName("ix_failed_error_imports_failed_at");
+
+ b.ToTable("failed_error_imports", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b =>
+ {
+ b.Property("UniqueMessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("unique_message_id");
+
+ b.Property("BodyContentType")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("body_content_type");
+
+ b.Property("BodySize")
+ .HasColumnType("integer")
+ .HasColumnName("body_size");
+
+ b.Property("BodyStoredExternally")
+ .HasColumnType("boolean")
+ .HasColumnName("body_stored_externally");
+
+ b.Property("BodyText")
+ .HasColumnType("text")
+ .HasColumnName("body_text");
+
+ b.Property("ConversationId")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("conversation_id");
+
+ b.Property("ExceptionMessage")
+ .HasColumnType("text")
+ .HasColumnName("exception_message");
+
+ b.Property("ExceptionType")
+ .HasColumnType("text")
+ .HasColumnName("exception_type");
+
+ b.Property("FailingEndpointAddress")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("failing_endpoint_address");
+
+ b.Property("FirstTimeOfFailure")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_time_of_failure");
+
+ b.Property("HeadersJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("headers_json");
+
+ b.Property("IsSystemMessage")
+ .HasColumnType("boolean")
+ .HasColumnName("is_system_message");
+
+ b.Property("LastAttemptedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_attempted_at");
+
+ b.Property("LastModified")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_modified");
+
+ b.Property("LastTimeOfFailure")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_time_of_failure");
+
+ b.Property("MessageId")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("message_id");
+
+ b.Property("MessageType")
+ .HasColumnType("text")
+ .HasColumnName("message_type");
+
+ b.Property("NumberOfProcessingAttempts")
+ .HasColumnType("integer")
+ .HasColumnName("number_of_processing_attempts");
+
+ b.Property("ReceivingEndpointHost")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("receiving_endpoint_host");
+
+ b.Property("ReceivingEndpointHostId")
+ .HasColumnType("uuid")
+ .HasColumnName("receiving_endpoint_host_id");
+
+ b.Property("ReceivingEndpointName")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("receiving_endpoint_name");
+
+ b.Property("SendingEndpointHost")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("sending_endpoint_host");
+
+ b.Property("SendingEndpointHostId")
+ .HasColumnType("uuid")
+ .HasColumnName("sending_endpoint_host_id");
+
+ b.Property("SendingEndpointName")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("sending_endpoint_name");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("StatusChangedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("status_changed_at");
+
+ b.Property("TimeSent")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("time_sent");
+
+ b.HasKey("UniqueMessageId")
+ .HasName("pk_failed_messages");
+
+ b.HasIndex("ConversationId")
+ .HasDatabaseName("ix_failed_messages_conversation_id");
+
+ b.HasIndex("FailingEndpointAddress")
+ .HasDatabaseName("ix_failed_messages_failing_endpoint_address");
+
+ b.HasIndex("ReceivingEndpointName")
+ .HasDatabaseName("ix_failed_messages_receiving_endpoint_name");
+
+ b.HasIndex("StatusChangedAt")
+ .HasDatabaseName("ix_failed_messages_status_changed_at")
+ .HasFilter("status IN (2, 4)");
+
+ b.HasIndex("TimeSent")
+ .HasDatabaseName("ix_failed_messages_time_sent");
+
+ b.HasIndex("Status", "LastModified")
+ .HasDatabaseName("ix_failed_messages_status_last_modified");
+
+ b.ToTable("failed_messages", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b =>
+ {
+ b.Property("FailedMessageUniqueId")
+ .HasColumnType("uuid")
+ .HasColumnName("failed_message_unique_id");
+
+ b.Property("GroupId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("group_id");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("type");
+
+ b.HasKey("FailedMessageUniqueId", "GroupId")
+ .HasName("pk_failed_message_groups");
+
+ b.HasIndex("GroupId")
+ .HasDatabaseName("ix_failed_message_groups_group_id");
+
+ b.HasIndex("Type", "GroupId")
+ .HasDatabaseName("ix_failed_message_groups_type_group_id");
+
+ b.ToTable("failed_message_groups", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b =>
+ {
+ b.Property("UniqueMessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("unique_message_id");
+
+ b.Property("RetryId")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("retry_id");
+
+ b.HasKey("UniqueMessageId")
+ .HasName("pk_failed_message_retries");
+
+ b.ToTable("failed_message_retries", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.GroupCommentEntity", b =>
+ {
+ b.Property("GroupId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("group_id");
+
+ b.Property("Comment")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("comment");
+
+ b.HasKey("GroupId")
+ .HasName("pk_group_comments");
+
+ b.ToTable("group_comments", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("host");
+
+ b.Property("HostId")
+ .HasColumnType("uuid")
+ .HasColumnName("host_id");
+
+ b.Property("Monitored")
+ .HasColumnType("boolean")
+ .HasColumnName("monitored");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_known_endpoints");
+
+ b.ToTable("known_endpoints", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b =>
+ {
+ b.Property("MessageType")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("message_type");
+
+ b.Property("TransportAddress")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("transport_address");
+
+ b.Property("Endpoint")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("endpoint");
+
+ b.HasKey("MessageType", "TransportAddress")
+ .HasName("pk_subscriptions");
+
+ b.ToTable("subscriptions", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("TrialEndDate")
+ .HasColumnType("date")
+ .HasColumnName("trial_end_date");
+
+ b.HasKey("Id")
+ .HasName("pk_trial_metadata");
+
+ b.ToTable("trial_metadata", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1
+ });
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b =>
+ {
+ b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null)
+ .WithMany()
+ .HasForeignKey("FailedMessageUniqueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_failed_message_groups_failed_messages_failed_message_unique");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260801000409_AddGroupComments.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260801000409_AddGroupComments.cs
new file mode 100644
index 0000000000..a6c01c90f0
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260801000409_AddGroupComments.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ ///
+ public partial class AddGroupComments : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "group_comments",
+ columns: table => new
+ {
+ group_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ comment = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_group_comments", x => x.group_id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "group_comments");
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs
index 1623ec1da1..f3013943d6 100644
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs
@@ -328,6 +328,24 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("failed_message_retries", (string)null);
});
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.GroupCommentEntity", b =>
+ {
+ b.Property("GroupId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("group_id");
+
+ b.Property("Comment")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("comment");
+
+ b.HasKey("GroupId")
+ .HasName("pk_group_comments");
+
+ b.ToTable("group_comments", (string)null);
+ });
+
modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
{
b.Property("Id")
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260801000400_AddGroupComments.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260801000400_AddGroupComments.Designer.cs
new file mode 100644
index 0000000000..336689eca6
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260801000400_AddGroupComments.Designer.cs
@@ -0,0 +1,362 @@
+//
+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.EFCore.SqlServer;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations
+{
+ [DbContext(typeof(SqlServerServiceControlDbContext))]
+ [Migration("20260801000400_AddGroupComments")]
+ partial class AddGroupComments
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b =>
+ {
+ b.Property("Name")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("TrackInstances")
+ .HasColumnType("bit");
+
+ b.HasKey("Name");
+
+ b.ToTable("EndpointSettings");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EventLogItemEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RaisedAt")
+ .HasColumnType("datetime2");
+
+ b.PrimitiveCollection("RelatedTo")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Severity")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RaisedAt", "Id")
+ .IsDescending();
+
+ b.ToTable("EventLogItems", (string)null);
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b =>
+ {
+ b.Property("UniqueMessageId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("varbinary(max)");
+
+ b.Property("BodyStoredExternally")
+ .HasColumnType("bit");
+
+ b.Property("ExceptionInfo")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FailedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("HeadersJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("MessageId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UniqueMessageId");
+
+ b.HasIndex("FailedAt");
+
+ b.ToTable("FailedErrorImports");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b =>
+ {
+ b.Property("UniqueMessageId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BodyContentType")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("BodySize")
+ .HasColumnType("int");
+
+ b.Property("BodyStoredExternally")
+ .HasColumnType("bit");
+
+ b.Property("BodyText")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ConversationId")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ExceptionMessage")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ExceptionType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FailingEndpointAddress")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("FirstTimeOfFailure")
+ .HasColumnType("datetime2");
+
+ b.Property("HeadersJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsSystemMessage")
+ .HasColumnType("bit");
+
+ b.Property("LastAttemptedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("LastModified")
+ .HasColumnType("datetime2");
+
+ b.Property("LastTimeOfFailure")
+ .HasColumnType("datetime2");
+
+ b.Property("MessageId")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("MessageType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("NumberOfProcessingAttempts")
+ .HasColumnType("int");
+
+ b.Property("ReceivingEndpointHost")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ReceivingEndpointHostId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ReceivingEndpointName")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("SendingEndpointHost")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("SendingEndpointHostId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("SendingEndpointName")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("StatusChangedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("TimeSent")
+ .HasColumnType("datetime2");
+
+ b.HasKey("UniqueMessageId");
+
+ b.HasIndex("ConversationId");
+
+ b.HasIndex("FailingEndpointAddress");
+
+ b.HasIndex("ReceivingEndpointName");
+
+ b.HasIndex("StatusChangedAt")
+ .HasFilter("[Status] IN (2, 4)");
+
+ b.HasIndex("TimeSent");
+
+ b.HasIndex("Status", "LastModified");
+
+ b.ToTable("FailedMessages");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b =>
+ {
+ b.Property("FailedMessageUniqueId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("GroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("FailedMessageUniqueId", "GroupId");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("Type", "GroupId");
+
+ b.ToTable("FailedMessageGroups");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b =>
+ {
+ b.Property("UniqueMessageId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RetryId")
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UniqueMessageId");
+
+ b.ToTable("FailedMessageRetries");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.GroupCommentEntity", b =>
+ {
+ b.Property("GroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Comment")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("GroupId");
+
+ b.ToTable("GroupComments");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("HostId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Monitored")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.ToTable("KnownEndpoints");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b =>
+ {
+ b.Property("MessageType")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("TransportAddress")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Endpoint")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("MessageType", "TransportAddress");
+
+ b.ToTable("Subscriptions");
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("TrialEndDate")
+ .HasColumnType("date");
+
+ b.HasKey("Id");
+
+ b.ToTable("TrialMetadata");
+
+ b.HasData(
+ new
+ {
+ Id = 1
+ });
+ });
+
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b =>
+ {
+ b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null)
+ .WithMany()
+ .HasForeignKey("FailedMessageUniqueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260801000400_AddGroupComments.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260801000400_AddGroupComments.cs
new file mode 100644
index 0000000000..9e612c113c
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260801000400_AddGroupComments.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations
+{
+ ///
+ public partial class AddGroupComments : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "GroupComments",
+ columns: table => new
+ {
+ GroupId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false),
+ Comment = table.Column(type: "nvarchar(max)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_GroupComments", x => x.GroupId);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "GroupComments");
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs
index 42cb2b31bd..fbaf83d1ad 100644
--- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs
@@ -262,6 +262,21 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("FailedMessageRetries");
});
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.GroupCommentEntity", b =>
+ {
+ b.Property("GroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Comment")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("GroupId");
+
+ b.ToTable("GroupComments");
+ });
+
modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
{
b.Property("Id")
diff --git a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs
index b6153edd23..d68c3b4c32 100644
--- a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs
+++ b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs
@@ -10,6 +10,7 @@ public abstract class ServiceControlDbContext(DbContextOptions options) : DbCont
public DbSet KnownEndpoints { get; set; }
public DbSet FailedMessages { get; set; }
public DbSet FailedMessageGroups { get; set; }
+ public DbSet GroupComments { get; set; }
public DbSet FailedMessageRetries { get; set; }
public DbSet FailedErrorImports { get; set; }
public DbSet TrialMetadata { get; set; }
@@ -28,6 +29,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.ApplyConfiguration(new FailedMessageConfiguration());
modelBuilder.ApplyConfiguration(new FailedMessageGroupConfiguration());
modelBuilder.ApplyConfiguration(new FailedMessageRetryConfiguration());
+ modelBuilder.ApplyConfiguration(new GroupCommentConfiguration());
modelBuilder.ApplyConfiguration(new KnownEndpointConfiguration());
modelBuilder.ApplyConfiguration(new SubscriptionConfiguration());
modelBuilder.ApplyConfiguration(new TrialMetadataConfiguration());
diff --git a/src/ServiceControl.Persistence.EFCore/Entities/GroupCommentEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/GroupCommentEntity.cs
new file mode 100644
index 0000000000..bacb6686d9
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore/Entities/GroupCommentEntity.cs
@@ -0,0 +1,8 @@
+namespace ServiceControl.Persistence.EFCore.Entities;
+
+public class GroupCommentEntity
+{
+ public required string GroupId { get; set; }
+
+ public required string Comment { get; set; }
+}
diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs
index 69c8244b27..b8de0c3757 100644
--- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs
+++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs
@@ -6,6 +6,9 @@ static class ColumnLengths
// nvarchar(max) columns cannot be index key columns.
public const int ShortTextLength = 450;
+ // Group ids are deterministic Guid strings, shared by the group rows and their comments.
+ public const int GroupIdLength = 64;
+
// The subscriptions key spans two columns and SQL Server caps a clustered index key at 900 bytes,
// so both have to stay well under ShortTextLength. Matches NServiceBus.Persistence.Sql.
public const int SubscriptionKeyLength = 200;
diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs
index 535913c3ef..750de7c3a5 100644
--- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs
+++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs
@@ -10,8 +10,7 @@ public void Configure(EntityTypeBuilder builder)
{
builder.HasKey(e => new { e.FailedMessageUniqueId, e.GroupId });
- // Group ids are deterministic Guid strings
- builder.Property(e => e.GroupId).HasMaxLength(64).IsRequired();
+ builder.Property(e => e.GroupId).HasMaxLength(ColumnLengths.GroupIdLength).IsRequired();
builder.Property(e => e.Title).IsRequired();
builder.Property(e => e.Type).HasMaxLength(255).IsRequired();
diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/GroupCommentConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/GroupCommentConfiguration.cs
new file mode 100644
index 0000000000..dbc7bb4876
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/GroupCommentConfiguration.cs
@@ -0,0 +1,16 @@
+namespace ServiceControl.Persistence.EFCore.EntityConfigurations;
+
+using Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+class GroupCommentConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(e => e.GroupId);
+
+ builder.Property(e => e.GroupId).HasMaxLength(ColumnLengths.GroupIdLength).ValueGeneratedNever();
+ builder.Property(e => e.Comment).IsRequired();
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs
index b967aed312..488d5a527e 100644
--- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs
+++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs
@@ -13,7 +13,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation;
public class GroupsDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IGroupsDataStore
{
public Task> GetUnresolvedGroupsByClassifier(string classifier, string classifierFilter) =>
- ExecuteWithDbContext(dbContext =>
+ ExecuteWithDbContext(async dbContext =>
{
var groups = ByClassifier(dbContext, classifier);
@@ -22,7 +22,11 @@ public Task> GetUnresolvedGroupsByClassifier(string clas
groups = groups.Where(group => group.Title == classifierFilter);
}
- return MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Unresolved)));
+ var views = await MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Unresolved)));
+
+ await AttachComments(dbContext, views);
+
+ return views;
});
public Task> GetArchivedGroupsByClassifier(string classifier) =>
@@ -42,10 +46,26 @@ public Task GetGroupErrorsCount(string groupId, string status, s
ExecuteWithDbContext(dbContext => InGroup(dbContext, groupId, status, modified).ToQueryStatsInfo());
public Task EditComment(string groupId, string comment) =>
- throw new NotImplementedException();
+ ExecuteWithDbContext(async dbContext =>
+ {
+ if (string.IsNullOrWhiteSpace(comment))
+ {
+ await RemoveComment(dbContext, groupId);
+ return;
+ }
+
+ await dbContext.UpsertAsync([groupId],
+ () => new GroupCommentEntity { GroupId = groupId, Comment = comment },
+ entity => entity.Comment = comment);
+ });
public Task DeleteComment(string groupId) =>
- throw new NotImplementedException();
+ ExecuteWithDbContext(dbContext => RemoveComment(dbContext, groupId));
+
+ static Task RemoveComment(ServiceControlDbContext dbContext, string groupId) =>
+ dbContext.GroupComments
+ .Where(groupComment => groupComment.GroupId == groupId)
+ .ExecuteDeleteAsync();
static IQueryable ByClassifier(ServiceControlDbContext dbContext, string classifier) =>
dbContext.FailedMessageGroups
@@ -77,6 +97,26 @@ static IQueryable InGroup(ServiceControlDbContext dbContext
.FilterByStatus(status)
.FilterByLastModifiedRange(modified);
+ static async Task AttachComments(ServiceControlDbContext dbContext, IList groups)
+ {
+ if (groups.Count == 0)
+ {
+ return;
+ }
+
+ var groupIds = groups.Select(group => group.Id).ToArray();
+
+ var comments = await dbContext.GroupComments
+ .AsNoTracking()
+ .Where(groupComment => groupIds.Contains(groupComment.GroupId))
+ .ToDictionaryAsync(groupComment => groupComment.GroupId, groupComment => groupComment.Comment);
+
+ foreach (var group in groups)
+ {
+ group.Comment = comments.GetValueOrDefault(group.Id);
+ }
+ }
+
static async Task> MostRecent(IQueryable groups) =>
await groups
.OrderByDescending(group => group.Last)
diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs
index 9fd1cb6b96..d5e25fec02 100644
--- a/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs
+++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs
@@ -60,6 +60,20 @@ async Task Sweep(bool pace, CancellationToken cancellationToken)
{
await SweepFailedMessages(pace, cancellationToken);
await SweepEventLogItems(pace, cancellationToken);
+ await SweepOrphanedGroupComments(cancellationToken);
+ }
+
+ // Once the last message of a group has been swept the group cannot be displayed at all, so its
+ // comment is unreachable. Leaving it behind would both accumulate invisible rows and, because
+ // group ids are deterministic, reattach a stale comment if the same failure ever recurs.
+ async Task SweepOrphanedGroupComments(CancellationToken cancellationToken)
+ {
+ using var scope = serviceScopeFactory.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ await dbContext.GroupComments
+ .Where(comment => !dbContext.FailedMessageGroups.Any(group => group.GroupId == comment.GroupId))
+ .ExecuteDeleteAsync(cancellationToken);
}
// Event log items are insert-only and carry no external bodies, so each batch is a single
diff --git a/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs b/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs
index 8d5e50e9f7..7e802522fc 100644
--- a/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs
+++ b/src/ServiceControl.Persistence.Tests/EFCore/ErrorIngestionTestBase.cs
@@ -64,6 +64,9 @@ protected async Task GetFailedMessage(Guid uniqueMessageId)
protected Task FindFailedMessage(Guid uniqueMessageId) =>
Query(dbContext => dbContext.FailedMessages.AsNoTracking().SingleOrDefaultAsync(m => m.UniqueMessageId == uniqueMessageId));
+ protected Task FindGroupComment(string groupId) =>
+ Query(dbContext => dbContext.GroupComments.AsNoTracking().SingleOrDefaultAsync(c => c.GroupId == groupId));
+
protected Task> GetGroups(Guid uniqueMessageId) =>
Query(dbContext => dbContext.FailedMessageGroups
.AsNoTracking()
diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs
index 5a2939e672..5919b446b6 100644
--- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs
+++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs
@@ -121,6 +121,47 @@ public async Task Tolerates_a_body_that_cannot_be_deleted()
}
}
+ [Test]
+ public async Task Deletes_comments_of_groups_that_no_longer_have_messages()
+ {
+ var expired = await SeedFailedMessage(FailedMessageStatus.Archived, Now.AddDays(-31));
+ var groupId = await SeedGroup(expired);
+
+ await GroupsStore.EditComment(groupId, "Raised with the shipping team");
+
+ await RunRetentionSweep();
+
+ Assert.That(await FindGroupComment(groupId), Is.Null);
+ }
+
+ [Test]
+ public async Task Keeps_comments_of_groups_that_still_have_messages()
+ {
+ var live = await SeedFailedMessage(FailedMessageStatus.Unresolved, Now);
+ var groupId = await SeedGroup(live);
+
+ await GroupsStore.EditComment(groupId, "Raised with the shipping team");
+
+ await RunRetentionSweep();
+
+ Assert.That(await FindGroupComment(groupId), Is.Not.Null);
+ }
+
+ async Task SeedGroup(Guid uniqueMessageId)
+ {
+ var groupId = Guid.NewGuid().ToString();
+
+ await Store(new FailedMessageGroupEntity
+ {
+ FailedMessageUniqueId = uniqueMessageId,
+ GroupId = groupId,
+ Title = "ShippingFailed",
+ Type = "Message Type"
+ });
+
+ return groupId;
+ }
+
async Task SeedFailedMessage(FailedMessageStatus status, DateTime statusChangedAt, bool bodyStoredExternally = false)
{
var id = Guid.NewGuid();
diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs
index d3a42859f7..783fc0ff76 100644
--- a/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs
+++ b/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs
@@ -226,6 +226,99 @@ public async Task Counts_the_errors_of_a_group()
Assert.That(stats.TotalCount, Is.EqualTo(2));
}
+ [Test]
+ public async Task Stores_a_comment_on_a_group()
+ {
+ var group = NewGroup("OrderPlaced");
+
+ await Insert(InGroup(group));
+ await EditComment(group.Id, "Waiting on the payment team");
+
+ Assert.That(await CommentFor(group), Is.EqualTo("Waiting on the payment team"));
+ }
+
+ [Test]
+ public async Task Overwrites_an_existing_comment()
+ {
+ var group = NewGroup("OrderPlaced");
+
+ await Insert(InGroup(group));
+ await EditComment(group.Id, "First");
+ await EditComment(group.Id, "Second");
+
+ Assert.That(await CommentFor(group), Is.EqualTo("Second"));
+ }
+
+ [Test]
+ public async Task Deletes_a_comment()
+ {
+ var group = NewGroup("OrderPlaced");
+
+ await Insert(InGroup(group));
+ await EditComment(group.Id, "No longer relevant");
+
+ await DeleteComment(group.Id);
+
+ Assert.That(await CommentFor(group), Is.Null);
+ }
+
+ [Test]
+ public async Task Removes_the_comment_when_it_is_cleared()
+ {
+ var group = NewGroup("OrderPlaced");
+
+ await Insert(InGroup(group));
+ await EditComment(group.Id, "No longer relevant");
+
+ await EditComment(group.Id, string.Empty);
+
+ Assert.That(await CommentFor(group), Is.Null.Or.Empty);
+ }
+
+ [Test]
+ public async Task Ignores_deleting_a_comment_that_is_not_there()
+ {
+ var group = NewGroup("OrderPlaced");
+
+ await Insert(InGroup(group));
+
+ await DeleteComment(group.Id);
+
+ Assert.That(await CommentFor(group), Is.Null);
+ }
+
+ [Test]
+ public async Task Leaves_archived_groups_without_their_comment()
+ {
+ var group = NewGroup("OrderPlaced");
+
+ await Insert(InGroup(group).ToFailedMessage(FailedMessageStatus.Archived));
+ await EditComment(group.Id, "Only shown on the open group");
+
+ var view = (await GroupsStore.GetArchivedGroupsByClassifier(Classifier)).Single();
+
+ Assert.That(view.Comment, Is.Null);
+ }
+
+ async Task CommentFor(FailedMessage.FailureGroup group)
+ {
+ var groups = await GroupsStore.GetUnresolvedGroupsByClassifier(Classifier, null);
+
+ return groups.Single(view => view.Id == group.Id).Comment;
+ }
+
+ async Task EditComment(string groupId, string comment)
+ {
+ await GroupsStore.EditComment(groupId, comment);
+ await CompleteDatabaseOperation();
+ }
+
+ async Task DeleteComment(string groupId)
+ {
+ await GroupsStore.DeleteComment(groupId);
+ await CompleteDatabaseOperation();
+ }
+
static FailedMessage.FailureGroup NewGroup(string title, string type = Classifier) =>
new() { Id = Guid.NewGuid().ToString(), Title = title, Type = type };