diff --git a/docs/error-ingestion-design.md b/docs/error-ingestion-design.md
new file mode 100644
index 0000000000..523fe70515
--- /dev/null
+++ b/docs/error-ingestion-design.md
@@ -0,0 +1,190 @@
+# Error ingestion design (relational persisters)
+
+## Overview
+
+The error instance receives failed messages on its input queue and stores them so they can be
+queried, grouped, retried, and eventually archived. This document describes how ingestion works
+for the relational persisters (PostgreSQL and SQL Server) and explains the design decisions that
+are not obvious from the code, in particular why the write path is hand-written SQL rather than
+ordinary change-tracked entity saves.
+
+The unit of ingestion is a **batch**: the transport hands the ingester up to `MaximumConcurrency`
+messages at a time, and the whole batch is written in a single database transaction. The relevant
+types are `EFIngestionUnitOfWork` (accumulation), `FailedMessageBatchWriter` (the write), and the
+per-provider `IIngestionSqlDialect` implementations (the statements that differ by provider).
+
+## Data model
+
+The schema stores **one row per failed message**, keyed by `UniqueMessageId`, in the
+`FailedMessages` table. There is deliberately **no attempts history table**. A message that fails
+repeatedly keeps a single row that records:
+
+- the **last** processing attempt (every denormalized and payload column comes from it),
+- the **number of distinct attempts** (`NumberOfProcessingAttempts`),
+- the **failure window** (`FirstTimeOfFailure`, `LastTimeOfFailure`).
+
+This is a deliberate difference from the document-database persister, which retained an array of
+attempts. The read side only ever consumed the last attempt plus the count, so storing the full
+history earned nothing and cost write amplification. It is also an improvement: because the count
+is a column rather than the length of a capped array, `NumberOfProcessingAttempts` always reports
+the true number of attempts, where Raven's implementation silently stopped counting once the
+retained array hit its cap of ten.
+
+### Stored source data vs derived columns
+
+Only two pieces of data are **stored as source of truth**: the full headers dictionary
+(`HeadersJson`) and the message body. Every other column (message type, endpoints, exception
+details, timestamps, and so on) is a **derived extraction** written purely so it can be indexed
+and queried. On read, the `FailureDetails` object and the metadata dictionary the rest of the
+system expects are **reconstructed** from the headers and these columns. Nothing downstream of
+ingestion reads a column expecting it to carry information the headers do not already contain.
+
+`BodyUrl`, `ContentType`, and `ContentLength` are examples worth calling out: the document store
+persisted them into a metadata dictionary, but they are all derivable (`BodyUrl` from the
+`UniqueMessageId`, the other two from the `BodyContentType` and `BodySize` columns), so they are
+not stored again.
+
+### Body placement
+
+Bodies are **always** stored. The `MaxBodySizeToStore` setting (default 100 KB) only decides
+*where*:
+
+- text at or under the cap: stored inline in `BodyText`, nothing external.
+- text over the cap: the full body goes to external storage, and a **search prefix** of at most
+ the cap, cut on a valid UTF-8 boundary, is kept inline in `BodyText` so search still works.
+- binary, or not strictly UTF-8 decodable, or containing a NUL byte: external storage only,
+ `BodyText` is null, regardless of size.
+
+When `BodyStoredExternally` is true the external copy is authoritative and `BodyText` is a
+search aid only; it must never be served as the body. `BodySize` is always the true original
+size. External writes happen before the row that points at them is committed.
+
+### Groups, endpoints, retention
+
+- **Failure groups** live in `FailedMessageGroups`, an association table. A message's group rows
+ are **replaced wholesale** on every attempt, because grouping is recomputed from the latest
+ attempt. Group views are query-time aggregates and are not part of ingestion.
+- **Known endpoints** are written in the same batch, **insert-if-absent**. An existing endpoint
+ row is never updated, which preserves the user-controlled `Monitored` flag.
+- **Retention** is driven by `StatusChangedAt`. A background sweeper deletes `Resolved` and
+ `Archived` rows older than `now - ErrorRetentionPeriod`, with the cutoff recomputed on every
+ run so a changed retention setting takes effect without rewriting rows. A filtered index on
+ `StatusChangedAt` restricted to those two statuses keeps the sweep cheap.
+
+### Full-text search
+
+Search over headers and body is provider-native and set up with raw SQL in the migrations: a
+stored generated `tsvector` column with a GIN index on PostgreSQL, and a full-text catalog and
+index on SQL Server. This is orthogonal to the write path but is another place where the
+relational features we want have no portable expression.
+
+## The write path
+
+`RecordFailedProcessingAttempt` is called **concurrently** for the messages in a batch (the
+ingester fans the batch out). `RecordKnownEndpoint` and `RecordSuccessfulRetry` are called
+afterwards. An EF `DbContext` is not thread-safe and must not be touched from multiple threads,
+so the `Record*` methods do no database work at all: they only enqueue into thread-safe
+collections. **All** database access happens later, on one thread, in `Complete`.
+
+`Complete` first waits for any external body writes, then hands the accumulated work to
+`FailedMessageBatchWriter.Write`, which:
+
+1. **Folds** the accumulated attempts in memory into one row per message plus its group rows. The
+ fold sorts a message's attempts by time, takes the last as the winner, counts distinct attempt
+ timestamps, and computes the failure window. The result is sorted by `UniqueMessageId` so that
+ concurrent writers tend to take row locks in the same order.
+2. Opens **one transaction** and runs the statements in a fixed order:
+ 1. upsert the failed-message rows,
+ 2. delete then re-insert the affected messages' group rows,
+ 3. insert-if-absent the known endpoints,
+ 4. resolve confirmed retries (set them `Resolved`, delete their retry rows),
+ 5. commit.
+
+The order matters: a message that both fails and is retry-confirmed in the same batch must end
+`Resolved`, so the retry resolution runs last.
+
+## Why the write is raw SQL
+
+Most of ServiceControl prefers standard abstractions, and the portable parts of this write path do
+use them: the group delete and the retry resolution are ordinary set-based EF operations
+(`ExecuteDelete`/`ExecuteUpdate`). The **upserts** are hand-written SQL, per provider, behind the
+`IIngestionSqlDialect` seam. Three requirements together force that, and no ORM-level API satisfies
+all three at once.
+
+### 1. The upsert is a conditional merge, not a save
+
+Writing a failed message is not "insert this row" or "update this row". For a message that already
+exists the statement must, in one shot:
+
+- flip the status back to `Unresolved`, and reset the retention clock **only** if the row was
+ previously resolved or archived,
+- add the batch's attempt count, but **not** if the batch merely redelivered the attempt already
+ stored (equal timestamps),
+- widen the failure window (min of the first, max of the last),
+- replace every payload column with the incoming values **only if** the incoming attempt is at
+ least as new as the stored one, so that an out-of-order older attempt still counts but does not
+ overwrite newer data.
+
+Those are per-column conditional expressions comparing the incoming row against the pre-update
+stored row. A change-tracked save cannot express them: it would have to read every row first,
+decide in memory, and write back, which is both slower and a race (see below). The logic has to
+execute **inside a single set-based statement** where every guard reads the same consistent row
+state.
+
+### 2. It must be correct under concurrent writers
+
+The instance runs a single ingestion loop today, but the write path is built so that multiple
+instances could ingest against the same database (for example to scale out under load). That means
+two transactions can try to write the **same** `UniqueMessageId` at the same time. Two hazards
+follow:
+
+- **Insert races.** Both writers see the row as absent and both insert, colliding on the primary
+ key.
+- **Read-modify-write cost.** EF's optimistic concurrency (a rowversion/xmin token) would catch a
+ conflicting write instead of silently losing it, but only via a read before every write and a
+ retry loop per message, the per-row round trip reason 3 rules out, and it still can't express the
+ conditional merge from reason 1.
+
+Closing the insert race requires the database's own concurrency-safe upsert primitive, and those
+are **provider-specific**:
+
+- PostgreSQL: `INSERT ... ON CONFLICT (unique_message_id) DO UPDATE`. The conflict clause makes a
+ concurrent insert fall through to the update instead of failing, and the whole statement is
+ atomic so the count arithmetic cannot lose an increment.
+- SQL Server: `MERGE ... WITH (HOLDLOCK)`. The lock hint serializes concurrent merges on the same
+ key so the second one sees the row and updates instead of colliding.
+
+These have no common surface. `ON CONFLICT` and `MERGE` are different grammars with different
+concurrency semantics (a plain `MERGE` on PostgreSQL is **not** insert-race safe, which is exactly
+why the PostgreSQL side uses `ON CONFLICT` instead). Expressing "the concurrency-safe conditional
+upsert for this database" therefore means writing the statement each database actually needs.
+
+### 3. It is set-based over a whole batch
+
+A batch can hold many messages. Saving them as tracked entities would be a statement per row and
+would, on SQL Server, run into the 2100-parameter limit for larger batches. The dialects instead
+send **chunked multi-row statements** (up to 50 rows each): a handful of fixed statement shapes
+that the database can cache a plan for, with no per-row round trips and no temporary tables.
+
+### What stays portable
+
+Only the genuinely divergent statements are raw. The retry resolution and the group delete are set
+based and identical across providers, so they remain EF operations in the shared writer. The raw
+SQL is confined to the two dialect classes, one per provider, each responsible only for the
+upserts. The guard semantics are kept identical between the two dialects; the shared test suite
+runs every ingestion test against both providers to keep them from drifting.
+
+## Transactions and retries
+
+Everything in a batch runs in one transaction opened by the writer. The raw dialect commands are
+explicitly enlisted onto that transaction, and the EF `ExecuteUpdate`/`ExecuteDelete` operations
+participate in it as well, so a failure anywhere rolls the whole batch back. Nothing is committed
+piecemeal.
+
+The transaction is wrapped in the provider's execution strategy so that a transient failure (a
+dropped connection, or a deadlock between concurrent writers) retries the **entire** batch. This is
+safe because the batch is **idempotent**: re-running it folds to the same rows, the upsert is a
+merge, the group rows are deleted and re-inserted, endpoints are insert-if-absent, and retry
+resolution is a set update plus delete. Replaying a batch after an ambiguous commit changes
+nothing. Stable lock ordering (the fold sorts by `UniqueMessageId`) keeps deadlocks rare in the
+first place.
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/FullTextSearchSql.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/FullTextSearchSql.cs
new file mode 100644
index 0000000000..2266b6e087
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/FullTextSearchSql.cs
@@ -0,0 +1,29 @@
+namespace ServiceControl.Persistence.EFCore.PostgreSql;
+
+///
+/// Full text search DDL for the failed messages table. EF Core cannot model a GIN index over an
+/// expression, so it is applied by the AddFullTextSearch migration. The statements live here, and
+/// not in the migration itself, so that regenerating the migrations with the dotnet-ef CLI only
+/// costs a one line migration body.
+///
+static class FullTextSearchSql
+{
+ const string IndexName = "ix_failed_messages_full_text";
+
+ // 'simple' rather than 'english': message and header content is technical, stemming and
+ // stopword removal do more harm than good.
+ // The default parser reads a dotted name as a single host token, so
+ // "ServiceControl.MessageFailures.MyMessage" would not match a search for "MyMessage". The
+ // message type is therefore also indexed with its separators replaced by spaces, mirroring
+ // the SearchableMessageType that MessageTypeEnricher already produces for RavenDB.
+ public const string Up = $"""
+ CREATE INDEX {IndexName}
+ ON failed_messages
+ USING GIN (to_tsvector('simple',
+ coalesce(headers_json, '') || ' ' ||
+ coalesce(body_text, '') || ' ' ||
+ replace(replace(coalesce(message_type, ''), '.', ' '), '+', ' ')))
+ """;
+
+ public const string Down = $"DROP INDEX IF EXISTS {IndexName}";
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Infrastructure/KnownEndpointsReconciler.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Infrastructure/KnownEndpointsReconciler.cs
deleted file mode 100644
index 666312ee06..0000000000
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Infrastructure/KnownEndpointsReconciler.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-namespace ServiceControl.Persistence.EFCore.PostgreSql.Infrastructure;
-
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using ServiceControl.Persistence.EFCore.DbContexts;
-using ServiceControl.Persistence.EFCore.Infrastructure;
-
-class KnownEndpointsReconciler(
- ILogger logger,
- TimeProvider timeProvider,
- IServiceScopeFactory serviceScopeFactory)
- : InsertOnlyTableReconciler(
- logger, timeProvider, serviceScopeFactory, nameof(KnownEndpointsReconciler))
-{
- protected override Task ReconcileBatch(ServiceControlDbContext dbContext, CancellationToken stoppingToken) =>
- ReconcileBatch(dbContext, BatchSize, stoppingToken);
-
- // Static so tests can execute a batch deterministically without the background service's timer loop.
- // Must be called within an active transaction because of the pg_try_advisory_xact_lock.
- internal static async Task ReconcileBatch(ServiceControlDbContext dbContext, int batchSize, CancellationToken cancellationToken)
- {
- var sql = """
- WITH lock_check AS (
- SELECT pg_try_advisory_xact_lock(hashtext('known_endpoints_sync')) AS acquired
- ),
- batch AS (
- SELECT ctid FROM "known_endpoints_insert_only"
- WHERE (SELECT acquired FROM lock_check)
- LIMIT @batchSize
- ),
- ins AS (
- INSERT INTO "known_endpoints" ("id", "name", "host_id", "host", "monitored")
- SELECT DISTINCT ON ("known_endpoint_id") "known_endpoint_id", "name", "host_id", "host", FALSE
- FROM "known_endpoints_insert_only"
- WHERE ctid IN (SELECT ctid FROM batch)
- ON CONFLICT ("id") DO NOTHING
- )
- DELETE FROM "known_endpoints_insert_only"
- WHERE ctid IN (SELECT ctid FROM batch);
- """;
-
- var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync(sql, [new Npgsql.NpgsqlParameter("@batchSize", batchSize)], cancellationToken);
- return rowsAffected;
- }
-}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260720230745_Initial.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260720230745_Initial.Designer.cs
deleted file mode 100644
index 1a1383b3e2..0000000000
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260720230745_Initial.Designer.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-//
-using System;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-using ServiceControl.Persistence.EFCore.PostgreSql;
-
-#nullable disable
-
-namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
-{
- [DbContext(typeof(PostgreSqlServiceControlDbContext))]
- [Migration("20260720230745_Initial")]
- partial class Initial
- {
- ///
- protected override void BuildTargetModel(ModelBuilder modelBuilder)
- {
-#pragma warning disable 612, 618
- modelBuilder
- .HasAnnotation("ProductVersion", "10.0.9")
- .HasAnnotation("Relational:MaxIdentifierLength", 63);
-
- NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
-
- modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
- {
- b.Property("Id")
- .HasColumnType("uuid")
- .HasColumnName("id");
-
- b.Property("Host")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("host");
-
- b.Property("HostId")
- .HasColumnType("uuid")
- .HasColumnName("host_id");
-
- b.Property("Monitored")
- .HasColumnType("boolean")
- .HasColumnName("monitored");
-
- b.Property("Name")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("name");
-
- b.HasKey("Id");
-
- b.ToTable("known_endpoints", (string)null);
- });
-
- modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointInsertOnlyEntity", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint")
- .HasColumnName("id");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("Host")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("host");
-
- b.Property("HostId")
- .HasColumnType("uuid")
- .HasColumnName("host_id");
-
- b.Property("KnownEndpointId")
- .HasColumnType("uuid")
- .HasColumnName("known_endpoint_id");
-
- b.Property("Name")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("name");
-
- b.HasKey("Id");
-
- b.HasIndex("KnownEndpointId");
-
- b.ToTable("known_endpoints_insert_only", (string)null);
- });
-#pragma warning restore 612, 618
- }
- }
-}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260720230745_Initial.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260720230745_Initial.cs
deleted file mode 100644
index d2caaa189a..0000000000
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260720230745_Initial.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using System;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-
-#nullable disable
-
-namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
-{
- ///
- public partial class Initial : Migration
- {
- ///
- protected override void Up(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.CreateTable(
- name: "known_endpoints",
- columns: table => new
- {
- id = table.Column(type: "uuid", nullable: false),
- name = table.Column(type: "text", nullable: false),
- host_id = table.Column(type: "uuid", nullable: false),
- host = table.Column(type: "text", nullable: false),
- monitored = table.Column(type: "boolean", nullable: false)
- },
- constraints: table =>
- {
- table.PrimaryKey("PK_known_endpoints", x => x.id);
- });
-
- migrationBuilder.CreateTable(
- name: "known_endpoints_insert_only",
- columns: table => new
- {
- id = table.Column(type: "bigint", nullable: false)
- .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
- known_endpoint_id = table.Column(type: "uuid", nullable: false),
- name = table.Column(type: "text", nullable: false),
- host_id = table.Column(type: "uuid", nullable: false),
- host = table.Column(type: "text", nullable: false)
- },
- constraints: table =>
- {
- table.PrimaryKey("PK_known_endpoints_insert_only", x => x.id);
- });
-
- migrationBuilder.CreateIndex(
- name: "IX_known_endpoints_insert_only_known_endpoint_id",
- table: "known_endpoints_insert_only",
- column: "known_endpoint_id");
- }
-
- ///
- protected override void Down(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.DropTable(
- name: "known_endpoints");
-
- migrationBuilder.DropTable(
- name: "known_endpoints_insert_only");
- }
- }
-}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061312_Initial.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061312_Initial.Designer.cs
new file mode 100644
index 0000000000..465e6178c6
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061312_Initial.Designer.cs
@@ -0,0 +1,262 @@
+//
+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.EFCore.PostgreSql;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ [DbContext(typeof(PostgreSqlServiceControlDbContext))]
+ [Migration("20260722061312_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ 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("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("QueueAddress")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("queue_address");
+
+ 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("QueueAddress")
+ .HasDatabaseName("ix_failed_messages_queue_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.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.KnownEndpointEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("host");
+
+ b.Property("HostId")
+ .HasColumnType("uuid")
+ .HasColumnName("host_id");
+
+ b.Property("Monitored")
+ .HasColumnType("boolean")
+ .HasColumnName("monitored");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_known_endpoints");
+
+ b.ToTable("known_endpoints", (string)null);
+ });
+
+ 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/20260722061312_Initial.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061312_Initial.cs
new file mode 100644
index 0000000000..d4b2543aa7
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061312_Initial.cs
@@ -0,0 +1,151 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "failed_message_retries",
+ columns: table => new
+ {
+ unique_message_id = table.Column(type: "uuid", nullable: false),
+ retry_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_failed_message_retries", x => x.unique_message_id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "failed_messages",
+ columns: table => new
+ {
+ unique_message_id = table.Column(type: "uuid", nullable: false),
+ status = table.Column(type: "integer", nullable: false),
+ status_changed_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_modified = table.Column(type: "timestamp with time zone", nullable: false),
+ number_of_processing_attempts = table.Column(type: "integer", nullable: false),
+ first_time_of_failure = table.Column(type: "timestamp with time zone", nullable: false),
+ last_time_of_failure = table.Column(type: "timestamp with time zone", nullable: false),
+ last_attempted_at = table.Column(type: "timestamp with time zone", nullable: false),
+ message_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ message_type = table.Column(type: "text", nullable: true),
+ time_sent = table.Column(type: "timestamp with time zone", nullable: true),
+ conversation_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ queue_address = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ sending_endpoint_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ sending_endpoint_host_id = table.Column(type: "uuid", nullable: true),
+ sending_endpoint_host = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ receiving_endpoint_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ receiving_endpoint_host_id = table.Column(type: "uuid", nullable: true),
+ receiving_endpoint_host = table.Column(type: "character varying(450)", maxLength: 450, nullable: true),
+ exception_type = table.Column(type: "text", nullable: true),
+ exception_message = table.Column(type: "text", nullable: true),
+ is_system_message = table.Column(type: "boolean", nullable: false),
+ headers_json = table.Column(type: "text", nullable: false),
+ body_text = table.Column(type: "text", nullable: true),
+ body_stored_externally = table.Column(type: "boolean", nullable: false),
+ body_size = table.Column(type: "integer", nullable: false),
+ body_content_type = table.Column(type: "character varying(450)", maxLength: 450, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_failed_messages", x => x.unique_message_id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "known_endpoints",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "text", nullable: false),
+ host_id = table.Column(type: "uuid", nullable: false),
+ host = table.Column(type: "text", nullable: false),
+ monitored = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_known_endpoints", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "failed_message_groups",
+ columns: table => new
+ {
+ failed_message_unique_id = table.Column(type: "uuid", nullable: false),
+ group_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ title = table.Column(type: "text", nullable: false),
+ type = table.Column(type: "character varying(255)", maxLength: 255, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_failed_message_groups", x => new { x.failed_message_unique_id, x.group_id });
+ table.ForeignKey(
+ name: "fk_failed_message_groups_failed_messages_failed_message_unique",
+ column: x => x.failed_message_unique_id,
+ principalTable: "failed_messages",
+ principalColumn: "unique_message_id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_message_groups_group_id",
+ table: "failed_message_groups",
+ column: "group_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_messages_conversation_id",
+ table: "failed_messages",
+ column: "conversation_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_messages_queue_address",
+ table: "failed_messages",
+ column: "queue_address");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_messages_receiving_endpoint_name",
+ table: "failed_messages",
+ column: "receiving_endpoint_name");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_messages_status_changed_at",
+ table: "failed_messages",
+ column: "status_changed_at",
+ filter: "status IN (2, 4)");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_messages_status_last_modified",
+ table: "failed_messages",
+ columns: new[] { "status", "last_modified" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_failed_messages_time_sent",
+ table: "failed_messages",
+ column: "time_sent");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "failed_message_groups");
+
+ migrationBuilder.DropTable(
+ name: "failed_message_retries");
+
+ migrationBuilder.DropTable(
+ name: "known_endpoints");
+
+ migrationBuilder.DropTable(
+ name: "failed_messages");
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061316_AddFullTextSearch.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061316_AddFullTextSearch.Designer.cs
new file mode 100644
index 0000000000..fe5a5b6bf3
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061316_AddFullTextSearch.Designer.cs
@@ -0,0 +1,262 @@
+//
+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.EFCore.PostgreSql;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ [DbContext(typeof(PostgreSqlServiceControlDbContext))]
+ [Migration("20260722061316_AddFullTextSearch")]
+ partial class AddFullTextSearch
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ 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("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("QueueAddress")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("queue_address");
+
+ 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("QueueAddress")
+ .HasDatabaseName("ix_failed_messages_queue_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.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.KnownEndpointEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("host");
+
+ b.Property("HostId")
+ .HasColumnType("uuid")
+ .HasColumnName("host_id");
+
+ b.Property("Monitored")
+ .HasColumnType("boolean")
+ .HasColumnName("monitored");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_known_endpoints");
+
+ b.ToTable("known_endpoints", (string)null);
+ });
+
+ 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/20260722061316_AddFullTextSearch.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061316_AddFullTextSearch.cs
new file mode 100644
index 0000000000..dfcd89e387
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260722061316_AddFullTextSearch.cs
@@ -0,0 +1,16 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ ///
+ public partial class AddFullTextSearch : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(FullTextSearchSql.Up);
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(FullTextSearchSql.Down);
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260723082351_AddKnownEndpointMaxLength.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260723082351_AddKnownEndpointMaxLength.Designer.cs
new file mode 100644
index 0000000000..08116b52f0
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260723082351_AddKnownEndpointMaxLength.Designer.cs
@@ -0,0 +1,264 @@
+//
+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.EFCore.PostgreSql;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ [DbContext(typeof(PostgreSqlServiceControlDbContext))]
+ [Migration("20260723082351_AddKnownEndpointMaxLength")]
+ partial class AddKnownEndpointMaxLength
+ {
+ ///
+ 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.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("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("QueueAddress")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("queue_address");
+
+ 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("QueueAddress")
+ .HasDatabaseName("ix_failed_messages_queue_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.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.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.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/20260723082351_AddKnownEndpointMaxLength.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260723082351_AddKnownEndpointMaxLength.cs
new file mode 100644
index 0000000000..75a997bed4
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260723082351_AddKnownEndpointMaxLength.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations
+{
+ ///
+ public partial class AddKnownEndpointMaxLength : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "name",
+ table: "known_endpoints",
+ type: "character varying(450)",
+ maxLength: 450,
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "text");
+
+ migrationBuilder.AlterColumn(
+ name: "host",
+ table: "known_endpoints",
+ type: "character varying(450)",
+ maxLength: 450,
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "text");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "name",
+ table: "known_endpoints",
+ type: "text",
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "character varying(450)",
+ oldMaxLength: 450);
+
+ migrationBuilder.AlterColumn(
+ name: "host",
+ table: "known_endpoints",
+ type: "text",
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "character varying(450)",
+ oldMaxLength: 450);
+ }
+ }
+}
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs
index 83b17187a4..fd525ada2c 100644
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs
@@ -17,72 +17,243 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
- modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b =>
{
- b.Property("Id")
+ b.Property("UniqueMessageId")
.HasColumnType("uuid")
- .HasColumnName("id");
+ .HasColumnName("unique_message_id");
- b.Property("Host")
+ 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("FirstTimeOfFailure")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_time_of_failure");
+
+ b.Property("HeadersJson")
.IsRequired()
.HasColumnType("text")
- .HasColumnName("host");
+ .HasColumnName("headers_json");
- b.Property("HostId")
+ 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("QueueAddress")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("queue_address");
+
+ b.Property("ReceivingEndpointHost")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("receiving_endpoint_host");
+
+ b.Property("ReceivingEndpointHostId")
.HasColumnType("uuid")
- .HasColumnName("host_id");
+ .HasColumnName("receiving_endpoint_host_id");
- b.Property("Monitored")
- .HasColumnType("boolean")
- .HasColumnName("monitored");
+ b.Property("ReceivingEndpointName")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
+ .HasColumnName("receiving_endpoint_name");
- b.Property("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("QueueAddress")
+ .HasDatabaseName("ix_failed_messages_queue_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("name");
+ .HasColumnName("title");
- b.HasKey("Id");
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("type");
- b.ToTable("known_endpoints", (string)null);
+ b.HasKey("FailedMessageUniqueId", "GroupId")
+ .HasName("pk_failed_message_groups");
+
+ b.HasIndex("GroupId")
+ .HasDatabaseName("ix_failed_message_groups_group_id");
+
+ b.ToTable("failed_message_groups", (string)null);
});
- modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointInsertOnlyEntity", b =>
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b =>
{
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint")
- .HasColumnName("id");
+ 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);
+ });
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
b.Property("Host")
.IsRequired()
- .HasColumnType("text")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
.HasColumnName("host");
b.Property("HostId")
.HasColumnType("uuid")
.HasColumnName("host_id");
- b.Property("KnownEndpointId")
- .HasColumnType("uuid")
- .HasColumnName("known_endpoint_id");
+ b.Property("Monitored")
+ .HasColumnType("boolean")
+ .HasColumnName("monitored");
b.Property("Name")
.IsRequired()
- .HasColumnType("text")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)")
.HasColumnName("name");
- b.HasKey("Id");
+ b.HasKey("Id")
+ .HasName("pk_known_endpoints");
- b.HasIndex("KnownEndpointId");
+ b.ToTable("known_endpoints", (string)null);
+ });
- b.ToTable("known_endpoints_insert_only", (string)null);
+ 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/PostgreSqlIngestionSqlDialect.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlIngestionSqlDialect.cs
new file mode 100644
index 0000000000..96421bd7bd
--- /dev/null
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlIngestionSqlDialect.cs
@@ -0,0 +1,175 @@
+namespace ServiceControl.Persistence.EFCore.PostgreSql;
+
+using System.Text;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using ServiceControl.MessageFailures;
+using ServiceControl.Persistence.EFCore.DbContexts;
+using ServiceControl.Persistence.EFCore.Entities;
+using ServiceControl.Persistence.EFCore.Infrastructure;
+
+// INSERT ... ON CONFLICT rather than MERGE: PostgreSQL's MERGE can fail with unique_violation
+// when two writers insert the same key concurrently, ON CONFLICT cannot. All references to the
+// target table inside DO UPDATE read the pre-update row, so the guards are consistent within one
+// atomic statement. Rows are chunked to keep statement texts down to a few reusable shapes.
+class PostgreSqlIngestionSqlDialect : IIngestionSqlDialect
+{
+ public async Task UpsertFailedMessages(ServiceControlDbContext dbContext, IReadOnlyList rows, CancellationToken cancellationToken)
+ {
+ foreach (var chunk in rows.Chunk(MaxRowsPerStatement))
+ {
+ await Execute(
+ dbContext,
+ $"""
+ INSERT INTO failed_messages ({FailedMessageColumnList})
+ VALUES
+ {ParameterRows(chunk.Length, FailedMessageColumns.Length)}
+ {OnConflictUpdate}
+ """,
+ chunk.Select(FailedMessageValues),
+ cancellationToken);
+ }
+ }
+
+ public async Task InsertGroups(ServiceControlDbContext dbContext, IReadOnlyList rows, CancellationToken cancellationToken)
+ {
+ foreach (var chunk in rows.Chunk(MaxRowsPerStatement))
+ {
+ await Execute(
+ dbContext,
+ $"""
+ INSERT INTO failed_message_groups (failed_message_unique_id, group_id, title, type)
+ VALUES
+ {ParameterRows(chunk.Length, 4)}
+ ON CONFLICT (failed_message_unique_id, group_id) DO NOTHING
+ """,
+ chunk.Select(group => new object?[] { group.FailedMessageUniqueId, group.GroupId, group.Title, group.Type }),
+ cancellationToken);
+ }
+ }
+
+ public async Task InsertMissingKnownEndpoints(ServiceControlDbContext dbContext, IReadOnlyList rows, CancellationToken cancellationToken)
+ {
+ foreach (var chunk in rows.Chunk(MaxRowsPerStatement))
+ {
+ await Execute(
+ dbContext,
+ $"""
+ INSERT INTO known_endpoints (id, name, host_id, host, monitored)
+ VALUES
+ {ParameterRows(chunk.Length, 5)}
+ ON CONFLICT (id) DO NOTHING
+ """,
+ chunk.Select(endpoint => new object?[] { endpoint.Id, endpoint.Name, endpoint.HostId, endpoint.Host, endpoint.Monitored }),
+ cancellationToken);
+ }
+ }
+
+ static async Task Execute(ServiceControlDbContext dbContext, string sql, IEnumerable