diff --git a/src/Mocha/Mocha.sln b/src/Mocha/Mocha.sln index 279aa211269..f3f126b0ee5 100644 --- a/src/Mocha/Mocha.sln +++ b/src/Mocha/Mocha.sln @@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo.ServiceDefaults", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo.Contracts", "src\Demo\Demo.Contracts\Demo.Contracts.csproj", "{58C302B9-4E15-447B-ACE3-867813189848}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mocha.Inbox", "src\Mocha.Inbox\Mocha.Inbox.csproj", "{0880A475-4F12-4974-A3FD-ABB4288909B2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -355,6 +357,18 @@ Global {58C302B9-4E15-447B-ACE3-867813189848}.Release|x64.Build.0 = Release|Any CPU {58C302B9-4E15-447B-ACE3-867813189848}.Release|x86.ActiveCfg = Release|Any CPU {58C302B9-4E15-447B-ACE3-867813189848}.Release|x86.Build.0 = Release|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Debug|x64.Build.0 = Debug|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Debug|x86.Build.0 = Debug|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Release|Any CPU.Build.0 = Release|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Release|x64.ActiveCfg = Release|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Release|x64.Build.0 = Release|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Release|x86.ActiveCfg = Release|Any CPU + {0880A475-4F12-4974-A3FD-ABB4288909B2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -382,5 +396,6 @@ Global {105A06D4-58C3-4A37-A590-C766D313AC8F} = {FB64595D-7A02-F2D4-9C1E-6F343453585F} {046A5F8B-AF61-4C9C-A2E7-F965A3DF3AF6} = {FB64595D-7A02-F2D4-9C1E-6F343453585F} {58C302B9-4E15-447B-ACE3-867813189848} = {FB64595D-7A02-F2D4-9C1E-6F343453585F} + {0880A475-4F12-4974-A3FD-ABB4288909B2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/Mocha/Mocha.slnx b/src/Mocha/Mocha.slnx index ba788ff0d17..a539d861564 100644 --- a/src/Mocha/Mocha.slnx +++ b/src/Mocha/Mocha.slnx @@ -1,2 +1,14 @@ + + + + + + + + + + + + diff --git a/src/Mocha/src/Demo/Demo.Billing/Data/BillingDbContext.cs b/src/Mocha/src/Demo/Demo.Billing/Data/BillingDbContext.cs index d6ccef5a3ec..3424f1a454f 100644 --- a/src/Mocha/src/Demo/Demo.Billing/Data/BillingDbContext.cs +++ b/src/Mocha/src/Demo/Demo.Billing/Data/BillingDbContext.cs @@ -1,6 +1,7 @@ using Demo.Billing.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Mocha.Inbox; using Mocha.Outbox; namespace Demo.Billing.Data; @@ -11,12 +12,14 @@ public class BillingDbContext(DbContextOptions options) : DbCo public DbSet Payments => Set(); public DbSet Refunds => Set(); public DbSet RevenueSummaries => Set(); + public DbSet InboxMessages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.AddPostgresOutbox(); + modelBuilder.AddPostgresInbox(); modelBuilder.Entity(entity => { diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260104231110_Init.Designer.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260104231110_Init.Designer.cs deleted file mode 100644 index 3bddd3796ef..00000000000 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260104231110_Init.Designer.cs +++ /dev/null @@ -1,118 +0,0 @@ -// -using System; -using Demo.Billing.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Billing.Migrations -{ - [DbContext(typeof(BillingDbContext))] - [Migration("20260104231110_Init")] - partial class Init - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Demo.Billing.Entities.Invoice", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("OrderId") - .HasColumnType("uuid"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("OrderId") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("Invoices"); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Payment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("InvoiceId") - .HasColumnType("uuid"); - - b.Property("Method") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ProcessedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("InvoiceId"); - - b.HasIndex("Status"); - - b.ToTable("Payments"); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Payment", b => - { - b.HasOne("Demo.Billing.Entities.Invoice", "Invoice") - .WithMany("Payments") - .HasForeignKey("InvoiceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Invoice"); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Invoice", b => - { - b.Navigation("Payments"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260104231110_Init.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260104231110_Init.cs deleted file mode 100644 index 48b10955377..00000000000 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260104231110_Init.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Billing.Migrations -{ - /// - public partial class Init : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Invoices", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - OrderId = table.Column(type: "uuid", nullable: false), - Amount = table.Column(type: "numeric(18,2)", nullable: false, precision: 18, scale: 2), - Status = table.Column(type: "integer", nullable: false), - CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - table.PrimaryKey("PK_Invoices", x => x.Id)); - - migrationBuilder.CreateTable( - name: "Payments", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - InvoiceId = table.Column(type: "uuid", nullable: false), - Amount = table.Column(type: "numeric(18,2)", nullable: false, precision: 18, scale: 2), - Method = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Status = table.Column(type: "integer", nullable: false), - ProcessedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Payments", x => x.Id); - table.ForeignKey( - name: "FK_Payments_Invoices_InvoiceId", - column: x => x.InvoiceId, - principalTable: "Invoices", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex(name: "IX_Invoices_CustomerId", table: "Invoices", column: "CustomerId"); - - migrationBuilder.CreateIndex( - name: "IX_Invoices_OrderId", - table: "Invoices", - column: "OrderId", - unique: true); - - migrationBuilder.CreateIndex(name: "IX_Invoices_Status", table: "Invoices", column: "Status"); - - migrationBuilder.CreateIndex(name: "IX_Payments_InvoiceId", table: "Payments", column: "InvoiceId"); - - migrationBuilder.CreateIndex(name: "IX_Payments_Status", table: "Payments", column: "Status"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "Payments"); - - migrationBuilder.DropTable(name: "Invoices"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260109160738_Outbox.Designer.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260109160738_Outbox.Designer.cs deleted file mode 100644 index f8d4534d6cd..00000000000 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260109160738_Outbox.Designer.cs +++ /dev/null @@ -1,149 +0,0 @@ -// -using System; -using System.Text.Json; -using Demo.Billing.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Billing.Migrations -{ - [DbContext(typeof(BillingDbContext))] - [Migration("20260109160738_Outbox")] - partial class Outbox - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt") - .IsDescending(); - - b.HasIndex("TimesSent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Invoice", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("OrderId") - .HasColumnType("uuid"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("OrderId") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("Invoices"); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Payment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Amount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("InvoiceId") - .HasColumnType("uuid"); - - b.Property("Method") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ProcessedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("InvoiceId"); - - b.HasIndex("Status"); - - b.ToTable("Payments"); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Payment", b => - { - b.HasOne("Demo.Billing.Entities.Invoice", "Invoice") - .WithMany("Payments") - .HasForeignKey("InvoiceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Invoice"); - }); - - modelBuilder.Entity("Demo.Billing.Entities.Invoice", b => - { - b.Navigation("Payments"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260109160738_Outbox.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260109160738_Outbox.cs deleted file mode 100644 index dc2a614e246..00000000000 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260109160738_Outbox.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Text.Json; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Billing.Migrations -{ - /// - public partial class Outbox : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "outbox_messages", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false), - envelope = table.Column(type: "json", nullable: false), - times_sent = table.Column(type: "integer", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - table.PrimaryKey("PK_outbox_messages", x => x.id)); - - migrationBuilder.CreateIndex( - name: "IX_outbox_messages_created_at", - table: "outbox_messages", - column: "created_at", - descending: new bool[0]); - - migrationBuilder.CreateIndex( - name: "IX_outbox_messages_times_sent", - table: "outbox_messages", - column: "times_sent"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "outbox_messages"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260111233102_AddRefundSaga.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260111233102_AddRefundSaga.cs deleted file mode 100644 index 3679254bd7e..00000000000 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260111233102_AddRefundSaga.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Billing.Migrations -{ - /// - public partial class AddRefundSaga : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropPrimaryKey(name: "PK_outbox_messages", table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "IX_outbox_messages_times_sent", - newName: "ix_outbox_messages_times_sent", - table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "IX_outbox_messages_created_at", - newName: "ix_outbox_messages_created_at", - table: "outbox_messages"); - - migrationBuilder.AddPrimaryKey( - name: "ix_outbox_messages_primary_key", - table: "outbox_messages", - column: "id"); - - migrationBuilder.CreateTable( - name: "Refunds", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - OrderId = table.Column(type: "uuid", nullable: false), - InvoiceId = table.Column(type: "uuid", nullable: true), - OriginalAmount = table.Column( - type: "numeric(18,2)", - nullable: false, - precision: 18, - scale: 2), - RefundedAmount = table.Column( - type: "numeric(18,2)", - nullable: false, - precision: 18, - scale: 2), - RefundPercentage = table.Column( - type: "numeric(5,2)", - nullable: false, - precision: 5, - scale: 2), - CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), - Status = table.Column(type: "integer", nullable: false), - Type = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - ProcessedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Refunds", x => x.Id); - table.ForeignKey( - name: "FK_Refunds_Invoices_InvoiceId", - column: x => x.InvoiceId, - principalTable: "Invoices", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex(name: "IX_Refunds_InvoiceId", table: "Refunds", column: "InvoiceId"); - - migrationBuilder.CreateIndex(name: "IX_Refunds_OrderId", table: "Refunds", column: "OrderId"); - - migrationBuilder.CreateIndex(name: "IX_Refunds_Status", table: "Refunds", column: "Status"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "Refunds"); - - migrationBuilder.DropPrimaryKey(name: "ix_outbox_messages_primary_key", table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "ix_outbox_messages_times_sent", - newName: "IX_outbox_messages_times_sent", - table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "ix_outbox_messages_created_at", - newName: "IX_outbox_messages_created_at", - table: "outbox_messages"); - - migrationBuilder.AddPrimaryKey(name: "PK_outbox_messages", table: "outbox_messages", column: "id"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260111233102_AddRefundSaga.Designer.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260309003629_Init.Designer.cs similarity index 71% rename from src/Mocha/src/Demo/Demo.Billing/Migrations/20260111233102_AddRefundSaga.Designer.cs rename to src/Mocha/src/Demo/Demo.Billing/Migrations/20260309003629_Init.Designer.cs index 937f3d4d6ab..d86800da0e7 100644 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260111233102_AddRefundSaga.Designer.cs +++ b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260309003629_Init.Designer.cs @@ -10,11 +10,11 @@ #nullable disable -namespace Demo.Billing.Migrations +namespace HotChocolate.Demo.Billing.Migrations { [DbContext(typeof(BillingDbContext))] - [Migration("20260111233102_AddRefundSaga")] - partial class AddRefundSaga + [Migration("20260309003629_Init")] + partial class Init { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -26,39 +26,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - modelBuilder.Entity("Demo.Billing.Entities.Invoice", b => { b.Property("Id") @@ -188,6 +155,111 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Refunds"); }); + modelBuilder.Entity("Demo.Billing.Entities.RevenueSummary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AverageOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CompletionMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderCount") + .HasColumnType("integer"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalItemsSold") + .HasColumnType("integer"); + + b.Property("TotalRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.ToTable("RevenueSummaries"); + }); + + modelBuilder.Entity("Mocha.Inbox.InboxMessage", b => + { + b.Property("MessageId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_id"); + + b.Property("ConsumerType") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("consumer_type"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_type"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("MessageId", "ConsumerType") + .HasName("ix_inbox_messages_primary_key"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_inbox_messages_processed_at"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Envelope") + .IsRequired() + .HasColumnType("json") + .HasColumnName("envelope"); + + b.Property("TimesSent") + .HasColumnType("integer") + .HasColumnName("times_sent"); + + b.HasKey("Id") + .HasName("ix_outbox_messages_primary_key"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("ix_outbox_messages_created_at"); + + b.HasIndex("TimesSent") + .HasDatabaseName("ix_outbox_messages_times_sent"); + + b.ToTable("outbox_messages", (string)null); + }); + modelBuilder.Entity("Demo.Billing.Entities.Payment", b => { b.HasOne("Demo.Billing.Entities.Invoice", "Invoice") diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/20260309003629_Init.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260309003629_Init.cs new file mode 100644 index 00000000000..f3c028ca5ed --- /dev/null +++ b/src/Mocha/src/Demo/Demo.Billing/Migrations/20260309003629_Init.cs @@ -0,0 +1,208 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HotChocolate.Demo.Billing.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + message_id = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + consumer_type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + message_type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("ix_inbox_messages_primary_key", x => new { x.message_id, x.consumer_type }); + }); + + migrationBuilder.CreateTable( + name: "Invoices", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Status = table.Column(type: "integer", nullable: false), + CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Invoices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + envelope = table.Column(type: "json", nullable: false), + times_sent = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("ix_outbox_messages_primary_key", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "RevenueSummaries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderCount = table.Column(type: "integer", nullable: false), + TotalRevenue = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + AverageOrderAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + TotalItemsSold = table.Column(type: "integer", nullable: false), + PeriodStart = table.Column(type: "timestamp with time zone", nullable: false), + PeriodEnd = table.Column(type: "timestamp with time zone", nullable: false), + CompletionMode = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RevenueSummaries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Payments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + InvoiceId = table.Column(type: "uuid", nullable: false), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Method = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Status = table.Column(type: "integer", nullable: false), + ProcessedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Payments", x => x.Id); + table.ForeignKey( + name: "FK_Payments_Invoices_InvoiceId", + column: x => x.InvoiceId, + principalTable: "Invoices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Refunds", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + InvoiceId = table.Column(type: "uuid", nullable: true), + OriginalAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + RefundedAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + RefundPercentage = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false), + CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "integer", nullable: false), + Type = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ProcessedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Refunds", x => x.Id); + table.ForeignKey( + name: "FK_Refunds_Invoices_InvoiceId", + column: x => x.InvoiceId, + principalTable: "Invoices", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_inbox_messages_processed_at", + table: "inbox_messages", + column: "processed_at"); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_CustomerId", + table: "Invoices", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_OrderId", + table: "Invoices", + column: "OrderId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_Status", + table: "Invoices", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_created_at", + table: "outbox_messages", + column: "created_at", + descending: new bool[0]); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_times_sent", + table: "outbox_messages", + column: "times_sent"); + + migrationBuilder.CreateIndex( + name: "IX_Payments_InvoiceId", + table: "Payments", + column: "InvoiceId"); + + migrationBuilder.CreateIndex( + name: "IX_Payments_Status", + table: "Payments", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_InvoiceId", + table: "Refunds", + column: "InvoiceId"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_OrderId", + table: "Refunds", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_Status", + table: "Refunds", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + + migrationBuilder.DropTable( + name: "Payments"); + + migrationBuilder.DropTable( + name: "Refunds"); + + migrationBuilder.DropTable( + name: "RevenueSummaries"); + + migrationBuilder.DropTable( + name: "Invoices"); + } + } +} diff --git a/src/Mocha/src/Demo/Demo.Billing/Migrations/BillingDbContextModelSnapshot.cs b/src/Mocha/src/Demo/Demo.Billing/Migrations/BillingDbContextModelSnapshot.cs index 14034b65e66..606e3a622e9 100644 --- a/src/Mocha/src/Demo/Demo.Billing/Migrations/BillingDbContextModelSnapshot.cs +++ b/src/Mocha/src/Demo/Demo.Billing/Migrations/BillingDbContextModelSnapshot.cs @@ -9,7 +9,7 @@ #nullable disable -namespace Demo.Billing.Migrations +namespace HotChocolate.Demo.Billing.Migrations { [DbContext(typeof(BillingDbContext))] partial class BillingDbContextModelSnapshot : ModelSnapshot @@ -23,39 +23,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - modelBuilder.Entity("Demo.Billing.Entities.Invoice", b => { b.Property("Id") @@ -185,6 +152,111 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Refunds"); }); + modelBuilder.Entity("Demo.Billing.Entities.RevenueSummary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AverageOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CompletionMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderCount") + .HasColumnType("integer"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalItemsSold") + .HasColumnType("integer"); + + b.Property("TotalRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.ToTable("RevenueSummaries"); + }); + + modelBuilder.Entity("Mocha.Inbox.InboxMessage", b => + { + b.Property("MessageId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_id"); + + b.Property("ConsumerType") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("consumer_type"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_type"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("MessageId", "ConsumerType") + .HasName("ix_inbox_messages_primary_key"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_inbox_messages_processed_at"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Envelope") + .IsRequired() + .HasColumnType("json") + .HasColumnName("envelope"); + + b.Property("TimesSent") + .HasColumnType("integer") + .HasColumnName("times_sent"); + + b.HasKey("Id") + .HasName("ix_outbox_messages_primary_key"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("ix_outbox_messages_created_at"); + + b.HasIndex("TimesSent") + .HasDatabaseName("ix_outbox_messages_times_sent"); + + b.ToTable("outbox_messages", (string)null); + }); + modelBuilder.Entity("Demo.Billing.Entities.Payment", b => { b.HasOne("Demo.Billing.Entities.Invoice", "Invoice") diff --git a/src/Mocha/src/Demo/Demo.Billing/Program.cs b/src/Mocha/src/Demo/Demo.Billing/Program.cs index c86b2da7d1a..59f166b654c 100644 --- a/src/Mocha/src/Demo/Demo.Billing/Program.cs +++ b/src/Mocha/src/Demo/Demo.Billing/Program.cs @@ -4,6 +4,9 @@ using Demo.Contracts.Events; using Microsoft.EntityFrameworkCore; using Mocha; +using Mocha.EntityFrameworkCore; +using Mocha.Inbox; +using Mocha.Outbox; using Mocha.Transport.RabbitMQ; var builder = WebApplication.CreateBuilder(args); @@ -37,6 +40,14 @@ // Request handlers for saga commands .AddRequestHandler() .AddRequestHandler() + .AddEntityFramework(p => + { + p.UsePostgresOutbox(); + + p.UseResilience(); + p.UseTransaction(); + p.UsePostgresInbox(); + }) .AddRabbitMQ(); var app = builder.Build(); diff --git a/src/Mocha/src/Demo/Demo.Catalog/Data/CatalogDbContext.cs b/src/Mocha/src/Demo/Demo.Catalog/Data/CatalogDbContext.cs index f2eaa229f21..bf5f80f8359 100644 --- a/src/Mocha/src/Demo/Demo.Catalog/Data/CatalogDbContext.cs +++ b/src/Mocha/src/Demo/Demo.Catalog/Data/CatalogDbContext.cs @@ -1,6 +1,7 @@ using Demo.Catalog.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Mocha.Inbox; using Mocha.Outbox; using Mocha.Sagas.EfCore; @@ -11,6 +12,7 @@ public class CatalogDbContext(DbContextOptions options) : DbCo public DbSet Products => Set(); public DbSet Categories => Set(); public DbSet Orders => Set(); + public DbSet InboxMessages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -18,6 +20,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.AddPostgresSagas(); modelBuilder.AddPostgresOutbox(); + modelBuilder.AddPostgresInbox(); modelBuilder.Entity(entity => { diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260104231158_Init.Designer.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260104231158_Init.Designer.cs deleted file mode 100644 index 85b01747f88..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260104231158_Init.Designer.cs +++ /dev/null @@ -1,208 +0,0 @@ -// -using System; -using Demo.Catalog.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20260104231158_Init")] - partial class Init - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Categories"); - - b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - Description = "Electronic devices and accessories", - Name = "Electronics" - }, - new - { - Id = new Guid("22222222-2222-2222-2222-222222222222"), - Description = "Physical and digital books", - Name = "Books" - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShippingAddress") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TotalAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("ProductId"); - - b.HasIndex("Status"); - - b.ToTable("Orders"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("StockQuantity") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.ToTable("Products"); - - b.HasData( - new - { - Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2355), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Premium noise-cancelling wireless headphones", - Name = "Wireless Headphones", - Price = 299.99m, - StockQuantity = 50, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2527), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2622), new TimeSpan(0, 0, 0, 0, 0)), - Description = "RGB mechanical gaming keyboard", - Name = "Mechanical Keyboard", - Price = 149.99m, - StockQuantity = 100, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2623), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - CategoryId = new Guid("22222222-2222-2222-2222-222222222222"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2628), new TimeSpan(0, 0, 0, 0, 0)), - Description = "A Handbook of Agile Software Craftsmanship by Robert C. Martin", - Name = "Clean Code", - Price = 39.99m, - StockQuantity = 200, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2629), new TimeSpan(0, 0, 0, 0, 0)) - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.HasOne("Demo.Catalog.Entities.Product", "Product") - .WithMany() - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.HasOne("Demo.Catalog.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId"); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Navigation("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260104231158_Init.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260104231158_Init.cs deleted file mode 100644 index 250cd5528c9..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260104231158_Init.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional - -namespace Demo.Catalog.Migrations -{ - /// - public partial class Init : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Categories", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Description = table.Column(type: "text", nullable: true) - }, - constraints: table => - table.PrimaryKey("PK_Categories", x => x.Id)); - - migrationBuilder.CreateTable( - name: "Products", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Description = table.Column( - type: "character varying(2000)", - maxLength: 2000, - nullable: false), - Price = table.Column(type: "numeric(18,2)", nullable: false, precision: 18, scale: 2), - StockQuantity = table.Column(type: "integer", nullable: false), - CategoryId = table.Column(type: "uuid", nullable: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Products", x => x.Id); - table.ForeignKey( - name: "FK_Products_Categories_CategoryId", - column: x => x.CategoryId, - principalTable: "Categories", - principalColumn: "Id"); - }); - - migrationBuilder.CreateTable( - name: "Orders", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - ProductId = table.Column(type: "uuid", nullable: false), - Quantity = table.Column(type: "integer", nullable: false), - CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - ShippingAddress = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: false), - TotalAmount = table.Column( - type: "numeric(18,2)", - nullable: false, - precision: 18, - scale: 2), - Status = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Orders", x => x.Id); - table.ForeignKey( - name: "FK_Orders_Products_ProductId", - column: x => x.ProductId, - principalTable: "Products", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.InsertData( - table: "Categories", - columns: new[] { "Id", "Description", "Name" }, - values: new object[,] - { - { - new Guid("11111111-1111-1111-1111-111111111111"), - "Electronic devices and accessories", - "Electronics" - }, - { new Guid("22222222-2222-2222-2222-222222222222"), "Physical and digital books", "Books" } - }); - - migrationBuilder.InsertData( - table: "Products", - columns: new[] - { - "Id", - "CategoryId", - "CreatedAt", - "Description", - "Name", - "Price", - "StockQuantity", - "UpdatedAt" - }, - values: new object[,] - { - { - new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - new Guid("11111111-1111-1111-1111-111111111111"), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2355), - new TimeSpan(0, 0, 0, 0, 0)), - "Premium noise-cancelling wireless headphones", - "Wireless Headphones", - 299.99m, - 50, - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2527), - new TimeSpan(0, 0, 0, 0, 0)) - }, - { - new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - new Guid("11111111-1111-1111-1111-111111111111"), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2622), - new TimeSpan(0, 0, 0, 0, 0)), - "RGB mechanical gaming keyboard", - "Mechanical Keyboard", - 149.99m, - 100, - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2623), - new TimeSpan(0, 0, 0, 0, 0)) - }, - { - new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - new Guid("22222222-2222-2222-2222-222222222222"), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2628), - new TimeSpan(0, 0, 0, 0, 0)), - "A Handbook of Agile Software Craftsmanship by Robert C. Martin", - "Clean Code", - 39.99m, - 200, - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2629), - new TimeSpan(0, 0, 0, 0, 0)) - } - }); - - migrationBuilder.CreateIndex(name: "IX_Orders_CustomerId", table: "Orders", column: "CustomerId"); - - migrationBuilder.CreateIndex(name: "IX_Orders_ProductId", table: "Orders", column: "ProductId"); - - migrationBuilder.CreateIndex(name: "IX_Orders_Status", table: "Orders", column: "Status"); - - migrationBuilder.CreateIndex(name: "IX_Products_CategoryId", table: "Products", column: "CategoryId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "Orders"); - - migrationBuilder.DropTable(name: "Products"); - - migrationBuilder.DropTable(name: "Categories"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260106180406_Outbox.Designer.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260106180406_Outbox.Designer.cs deleted file mode 100644 index f33e69f3e42..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260106180406_Outbox.Designer.cs +++ /dev/null @@ -1,239 +0,0 @@ -// -using System; -using System.Text.Json; -using Demo.Catalog.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20260106180406_Outbox")] - partial class Outbox - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt") - .IsDescending(); - - b.HasIndex("TimesSent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Categories"); - - b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - Description = "Electronic devices and accessories", - Name = "Electronics" - }, - new - { - Id = new Guid("22222222-2222-2222-2222-222222222222"), - Description = "Physical and digital books", - Name = "Books" - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShippingAddress") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TotalAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("ProductId"); - - b.HasIndex("Status"); - - b.ToTable("Orders"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("StockQuantity") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.ToTable("Products"); - - b.HasData( - new - { - Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(7989), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Premium noise-cancelling wireless headphones", - Name = "Wireless Headphones", - Price = 299.99m, - StockQuantity = 50, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8169), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8241), new TimeSpan(0, 0, 0, 0, 0)), - Description = "RGB mechanical gaming keyboard", - Name = "Mechanical Keyboard", - Price = 149.99m, - StockQuantity = 100, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8241), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - CategoryId = new Guid("22222222-2222-2222-2222-222222222222"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8244), new TimeSpan(0, 0, 0, 0, 0)), - Description = "A Handbook of Agile Software Craftsmanship by Robert C. Martin", - Name = "Clean Code", - Price = 39.99m, - StockQuantity = 200, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8244), new TimeSpan(0, 0, 0, 0, 0)) - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.HasOne("Demo.Catalog.Entities.Product", "Product") - .WithMany() - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.HasOne("Demo.Catalog.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId"); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Navigation("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260106180406_Outbox.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260106180406_Outbox.cs deleted file mode 100644 index 7b3dbb619e2..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260106180406_Outbox.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Text.Json; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - /// - public partial class Outbox : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "outbox_messages", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false), - envelope = table.Column(type: "json", nullable: false), - times_sent = table.Column(type: "integer", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - table.PrimaryKey("PK_outbox_messages", x => x.id)); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(7989), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8169), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8241), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8241), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8244), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8244), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.CreateIndex( - name: "IX_outbox_messages_created_at", - table: "outbox_messages", - column: "created_at", - descending: new bool[0]); - - migrationBuilder.CreateIndex( - name: "IX_outbox_messages_times_sent", - table: "outbox_messages", - column: "times_sent"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "outbox_messages"); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2355), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2527), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2622), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2623), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2628), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified).AddTicks(2629), - new TimeSpan(0, 0, 0, 0, 0)) - }); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260107184104_OutboxJsonDoc.Designer.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260107184104_OutboxJsonDoc.Designer.cs deleted file mode 100644 index d6710c1e399..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260107184104_OutboxJsonDoc.Designer.cs +++ /dev/null @@ -1,239 +0,0 @@ -// -using System; -using System.Text.Json; -using Demo.Catalog.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20260107184104_OutboxJsonDoc")] - partial class OutboxJsonDoc - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt") - .IsDescending(); - - b.HasIndex("TimesSent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Categories"); - - b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - Description = "Electronic devices and accessories", - Name = "Electronics" - }, - new - { - Id = new Guid("22222222-2222-2222-2222-222222222222"), - Description = "Physical and digital books", - Name = "Books" - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShippingAddress") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TotalAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("ProductId"); - - b.HasIndex("Status"); - - b.ToTable("Orders"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("StockQuantity") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.ToTable("Products"); - - b.HasData( - new - { - Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Premium noise-cancelling wireless headphones", - Name = "Wireless Headphones", - Price = 299.99m, - StockQuantity = 50, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "RGB mechanical gaming keyboard", - Name = "Mechanical Keyboard", - Price = 149.99m, - StockQuantity = 100, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - CategoryId = new Guid("22222222-2222-2222-2222-222222222222"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "A Handbook of Agile Software Craftsmanship by Robert C. Martin", - Name = "Clean Code", - Price = 39.99m, - StockQuantity = 200, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.HasOne("Demo.Catalog.Entities.Product", "Product") - .WithMany() - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.HasOne("Demo.Catalog.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId"); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Navigation("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260107184104_OutboxJsonDoc.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260107184104_OutboxJsonDoc.cs deleted file mode 100644 index 7eda4e40fbc..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260107184104_OutboxJsonDoc.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - /// - public partial class OutboxJsonDoc : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), - new TimeSpan(0, 0, 0, 0, 0)) - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(7989), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8169), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8241), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8241), - new TimeSpan(0, 0, 0, 0, 0)) - }); - - migrationBuilder.UpdateData( - table: "Products", - keyColumn: "Id", - keyValue: new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - columns: new[] { "CreatedAt", "UpdatedAt" }, - values: new object[] - { - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8244), - new TimeSpan(0, 0, 0, 0, 0)), - new DateTimeOffset( - new DateTime(2026, 1, 6, 18, 4, 6, 412, DateTimeKind.Unspecified).AddTicks(8244), - new TimeSpan(0, 0, 0, 0, 0)) - }); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204021_Sagas.Designer.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204021_Sagas.Designer.cs deleted file mode 100644 index cb2fead05dc..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204021_Sagas.Designer.cs +++ /dev/null @@ -1,275 +0,0 @@ -// -using System; -using System.Text.Json; -using Demo.Catalog.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20260111204021_Sagas")] - partial class Sagas - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt") - .IsDescending(); - - b.HasIndex("TimesSent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Mocha.Sagas.EfCore.SagaState", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("SagaName") - .HasColumnType("text") - .HasColumnName("saga_name"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("State") - .IsRequired() - .HasColumnType("json") - .HasColumnName("state"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.Property("Version") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("version"); - - b.HasKey("Id", "SagaName"); - - b.HasIndex("CreatedAt"); - - b.ToTable("saga_states", (string)null); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Categories"); - - b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - Description = "Electronic devices and accessories", - Name = "Electronics" - }, - new - { - Id = new Guid("22222222-2222-2222-2222-222222222222"), - Description = "Physical and digital books", - Name = "Books" - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShippingAddress") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TotalAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("ProductId"); - - b.HasIndex("Status"); - - b.ToTable("Orders"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("StockQuantity") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.ToTable("Products"); - - b.HasData( - new - { - Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Premium noise-cancelling wireless headphones", - Name = "Wireless Headphones", - Price = 299.99m, - StockQuantity = 50, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "RGB mechanical gaming keyboard", - Name = "Mechanical Keyboard", - Price = 149.99m, - StockQuantity = 100, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - CategoryId = new Guid("22222222-2222-2222-2222-222222222222"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "A Handbook of Agile Software Craftsmanship by Robert C. Martin", - Name = "Clean Code", - Price = 39.99m, - StockQuantity = 200, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.HasOne("Demo.Catalog.Entities.Product", "Product") - .WithMany() - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.HasOne("Demo.Catalog.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId"); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Navigation("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204021_Sagas.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204021_Sagas.cs deleted file mode 100644 index 7910b2a3a77..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204021_Sagas.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Text.Json; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - /// - public partial class Sagas : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "saga_states", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false), - saga_name = table.Column(type: "text", nullable: false), - state = table.Column(type: "json", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false), - updated_at = table.Column(type: "timestamp with time zone", nullable: false), - version = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - table.PrimaryKey("PK_saga_states", x => new { x.id, x.saga_name })); - - migrationBuilder.CreateIndex(name: "IX_saga_states_created_at", table: "saga_states", column: "created_at"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "saga_states"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204334_AdjustIndexNames.Designer.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204334_AdjustIndexNames.Designer.cs deleted file mode 100644 index 4c0099ce3ab..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204334_AdjustIndexNames.Designer.cs +++ /dev/null @@ -1,280 +0,0 @@ -// -using System; -using System.Text.Json; -using Demo.Catalog.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20260111204334_AdjustIndexNames")] - partial class AdjustIndexNames - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Mocha.Sagas.EfCore.SagaState", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("SagaName") - .HasColumnType("text") - .HasColumnName("saga_name"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("State") - .IsRequired() - .HasColumnType("json") - .HasColumnName("state"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.Property("Version") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("version"); - - b.HasKey("Id", "SagaName") - .HasName("ix_saga_states_primary_key"); - - b.HasIndex("CreatedAt") - .HasDatabaseName("ix_saga_states_created_at"); - - b.ToTable("saga_states", (string)null); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Categories"); - - b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - Description = "Electronic devices and accessories", - Name = "Electronics" - }, - new - { - Id = new Guid("22222222-2222-2222-2222-222222222222"), - Description = "Physical and digital books", - Name = "Books" - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CustomerId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShippingAddress") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TotalAmount") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CustomerId"); - - b.HasIndex("ProductId"); - - b.HasIndex("Status"); - - b.ToTable("Orders"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.Property("StockQuantity") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.ToTable("Products"); - - b.HasData( - new - { - Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Premium noise-cancelling wireless headphones", - Name = "Wireless Headphones", - Price = 299.99m, - StockQuantity = 50, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CategoryId = new Guid("11111111-1111-1111-1111-111111111111"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "RGB mechanical gaming keyboard", - Name = "Mechanical Keyboard", - Price = 149.99m, - StockQuantity = 100, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), - CategoryId = new Guid("22222222-2222-2222-2222-222222222222"), - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "A Handbook of Agile Software Craftsmanship by Robert C. Martin", - Name = "Clean Code", - Price = 39.99m, - StockQuantity = 200, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => - { - b.HasOne("Demo.Catalog.Entities.Product", "Product") - .WithMany() - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Product", b => - { - b.HasOne("Demo.Catalog.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId"); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => - { - b.Navigation("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204334_AdjustIndexNames.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204334_AdjustIndexNames.cs deleted file mode 100644 index 362f11880df..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260111204334_AdjustIndexNames.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - /// - public partial class AdjustIndexNames : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropPrimaryKey(name: "PK_saga_states", table: "saga_states"); - - migrationBuilder.DropPrimaryKey(name: "PK_outbox_messages", table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "IX_saga_states_created_at", - newName: "ix_saga_states_created_at", - table: "saga_states"); - - migrationBuilder.RenameIndex( - name: "IX_outbox_messages_times_sent", - newName: "ix_outbox_messages_times_sent", - table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "IX_outbox_messages_created_at", - newName: "ix_outbox_messages_created_at", - table: "outbox_messages"); - - migrationBuilder.AddPrimaryKey( - name: "ix_saga_states_primary_key", - table: "saga_states", - columns: new[] { "id", "saga_name" }); - - migrationBuilder.AddPrimaryKey( - name: "ix_outbox_messages_primary_key", - table: "outbox_messages", - column: "id"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropPrimaryKey(name: "ix_saga_states_primary_key", table: "saga_states"); - - migrationBuilder.DropPrimaryKey(name: "ix_outbox_messages_primary_key", table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "ix_saga_states_created_at", - newName: "IX_saga_states_created_at", - table: "saga_states"); - - migrationBuilder.RenameIndex( - name: "ix_outbox_messages_times_sent", - newName: "IX_outbox_messages_times_sent", - table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "ix_outbox_messages_created_at", - newName: "IX_outbox_messages_created_at", - table: "outbox_messages"); - - migrationBuilder.AddPrimaryKey( - name: "PK_saga_states", - table: "saga_states", - columns: new[] { "id", "saga_name" }); - - migrationBuilder.AddPrimaryKey(name: "PK_outbox_messages", table: "outbox_messages", column: "id"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260114183145_ChangeAppManagedCOncurrencyToken.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260114183145_ChangeAppManagedCOncurrencyToken.cs deleted file mode 100644 index bac8e2ab319..00000000000 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260114183145_ChangeAppManagedCOncurrencyToken.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Catalog.Migrations -{ - /// - public partial class ChangeAppManagedCOncurrencyToken : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "version", - table: "saga_states", - type: "uuid", - nullable: false, - oldClrType: typeof(uint), - oldType: "xid", - oldRowVersion: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "version", - table: "saga_states", - type: "xid", - rowVersion: true, - nullable: false, - oldClrType: typeof(Guid), - oldType: "uuid"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260114183145_ChangeAppManagedCOncurrencyToken.Designer.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260309003634_Init.Designer.cs similarity index 88% rename from src/Mocha/src/Demo/Demo.Catalog/Migrations/20260114183145_ChangeAppManagedCOncurrencyToken.Designer.cs rename to src/Mocha/src/Demo/Demo.Catalog/Migrations/20260309003634_Init.Designer.cs index 12d4466b042..9bf9c0ae8a5 100644 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260114183145_ChangeAppManagedCOncurrencyToken.Designer.cs +++ b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260309003634_Init.Designer.cs @@ -10,11 +10,11 @@ #nullable disable -namespace Demo.Catalog.Migrations +namespace HotChocolate.Demo.Catalog.Migrations { [DbContext(typeof(CatalogDbContext))] - [Migration("20260114183145_ChangeAppManagedCOncurrencyToken")] - partial class ChangeAppManagedCOncurrencyToken + [Migration("20260309003634_Init")] + partial class Init { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -26,76 +26,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Mocha.Sagas.EfCore.SagaState", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("SagaName") - .HasColumnType("text") - .HasColumnName("saga_name"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("State") - .IsRequired() - .HasColumnType("json") - .HasColumnName("state"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.Property("Version") - .IsConcurrencyToken() - .HasColumnType("uuid") - .HasColumnName("version"); - - b.HasKey("Id", "SagaName") - .HasName("ix_saga_states_primary_key"); - - b.HasIndex("CreatedAt") - .HasDatabaseName("ix_saga_states_created_at"); - - b.ToTable("saga_states", (string)null); - }); - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => { b.Property("Id") @@ -249,6 +179,109 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("Mocha.Inbox.InboxMessage", b => + { + b.Property("MessageId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_id"); + + b.Property("ConsumerType") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("consumer_type"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_type"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("MessageId", "ConsumerType") + .HasName("ix_inbox_messages_primary_key"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_inbox_messages_processed_at"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Envelope") + .IsRequired() + .HasColumnType("json") + .HasColumnName("envelope"); + + b.Property("TimesSent") + .HasColumnType("integer") + .HasColumnName("times_sent"); + + b.HasKey("Id") + .HasName("ix_outbox_messages_primary_key"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("ix_outbox_messages_created_at"); + + b.HasIndex("TimesSent") + .HasDatabaseName("ix_outbox_messages_times_sent"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Sagas.EfCore.SagaState", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("SagaName") + .HasColumnType("text") + .HasColumnName("saga_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("State") + .IsRequired() + .HasColumnType("json") + .HasColumnName("state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("version"); + + b.HasKey("Id", "SagaName") + .HasName("ix_saga_states_primary_key"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_saga_states_created_at"); + + b.ToTable("saga_states", (string)null); + }); + modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => { b.HasOne("Demo.Catalog.Entities.Product", "Product") diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260309003634_Init.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260309003634_Init.cs new file mode 100644 index 00000000000..ec4b7046ebf --- /dev/null +++ b/src/Mocha/src/Demo/Demo.Catalog/Migrations/20260309003634_Init.cs @@ -0,0 +1,193 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace HotChocolate.Demo.Catalog.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "text", nullable: true) + }, + constraints: table => table.PrimaryKey("PK_Categories", x => x.Id)); + + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + message_id = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + consumer_type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + message_type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => table.PrimaryKey("ix_inbox_messages_primary_key", x => new { x.message_id, x.consumer_type })); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + envelope = table.Column(type: "json", nullable: false), + times_sent = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => table.PrimaryKey("ix_outbox_messages_primary_key", x => x.id)); + + migrationBuilder.CreateTable( + name: "saga_states", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + saga_name = table.Column(type: "text", nullable: false), + state = table.Column(type: "json", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + version = table.Column(type: "uuid", nullable: false) + }, + constraints: table => table.PrimaryKey("ix_saga_states_primary_key", x => new { x.id, x.saga_name })); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + Price = table.Column(type: "numeric(18,2)", nullable: false, precision: 18, scale: 2), + StockQuantity = table.Column(type: "integer", nullable: false), + CategoryId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + table.ForeignKey( + name: "FK_Products_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + Quantity = table.Column(type: "integer", nullable: false), + CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ShippingAddress = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + TotalAmount = table.Column(type: "numeric(18,2)", nullable: false, precision: 18, scale: 2), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + table.ForeignKey( + name: "FK_Orders_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "Id", "Description", "Name" }, + values: new object[,] + { + { new Guid("11111111-1111-1111-1111-111111111111"), "Electronic devices and accessories", "Electronics" }, + { new Guid("22222222-2222-2222-2222-222222222222"), "Physical and digital books", "Books" } + }); + + migrationBuilder.InsertData( + table: "Products", + columns: new[] { "Id", "CategoryId", "CreatedAt", "Description", "Name", "Price", "StockQuantity", "UpdatedAt" }, + values: new object[,] + { + { new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), new Guid("11111111-1111-1111-1111-111111111111"), new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "Premium noise-cancelling wireless headphones", "Wireless Headphones", 299.99m, 50, new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, + { new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), new Guid("11111111-1111-1111-1111-111111111111"), new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "RGB mechanical gaming keyboard", "Mechanical Keyboard", 149.99m, 100, new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) }, + { new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), new Guid("22222222-2222-2222-2222-222222222222"), new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "A Handbook of Agile Software Craftsmanship by Robert C. Martin", "Clean Code", 39.99m, 200, new DateTimeOffset(new DateTime(2026, 1, 4, 23, 11, 57, 852, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) } + }); + + migrationBuilder.CreateIndex( + name: "ix_inbox_messages_processed_at", + table: "inbox_messages", + column: "processed_at"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_CustomerId", + table: "Orders", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_ProductId", + table: "Orders", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_Status", + table: "Orders", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_created_at", + table: "outbox_messages", + column: "created_at", + descending: new bool[0]); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_times_sent", + table: "outbox_messages", + column: "times_sent"); + + migrationBuilder.CreateIndex( + name: "IX_Products_CategoryId", + table: "Products", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "ix_saga_states_created_at", + table: "saga_states", + column: "created_at"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "Orders"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + + migrationBuilder.DropTable( + name: "saga_states"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/src/Mocha/src/Demo/Demo.Catalog/Migrations/CatalogDbContextModelSnapshot.cs b/src/Mocha/src/Demo/Demo.Catalog/Migrations/CatalogDbContextModelSnapshot.cs index 29d4def365c..86486f4b0f2 100644 --- a/src/Mocha/src/Demo/Demo.Catalog/Migrations/CatalogDbContextModelSnapshot.cs +++ b/src/Mocha/src/Demo/Demo.Catalog/Migrations/CatalogDbContextModelSnapshot.cs @@ -9,7 +9,7 @@ #nullable disable -namespace Demo.Catalog.Migrations +namespace HotChocolate.Demo.Catalog.Migrations { [DbContext(typeof(CatalogDbContext))] partial class CatalogDbContextModelSnapshot : ModelSnapshot @@ -23,76 +23,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Mocha.Sagas.EfCore.SagaState", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("SagaName") - .HasColumnType("text") - .HasColumnName("saga_name"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("State") - .IsRequired() - .HasColumnType("json") - .HasColumnName("state"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.Property("Version") - .IsConcurrencyToken() - .HasColumnType("uuid") - .HasColumnName("version"); - - b.HasKey("Id", "SagaName") - .HasName("ix_saga_states_primary_key"); - - b.HasIndex("CreatedAt") - .HasDatabaseName("ix_saga_states_created_at"); - - b.ToTable("saga_states", (string)null); - }); - modelBuilder.Entity("Demo.Catalog.Entities.Category", b => { b.Property("Id") @@ -246,6 +176,109 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("Mocha.Inbox.InboxMessage", b => + { + b.Property("MessageId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_id"); + + b.Property("ConsumerType") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("consumer_type"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_type"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("MessageId", "ConsumerType") + .HasName("ix_inbox_messages_primary_key"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_inbox_messages_processed_at"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Envelope") + .IsRequired() + .HasColumnType("json") + .HasColumnName("envelope"); + + b.Property("TimesSent") + .HasColumnType("integer") + .HasColumnName("times_sent"); + + b.HasKey("Id") + .HasName("ix_outbox_messages_primary_key"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("ix_outbox_messages_created_at"); + + b.HasIndex("TimesSent") + .HasDatabaseName("ix_outbox_messages_times_sent"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Sagas.EfCore.SagaState", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("SagaName") + .HasColumnType("text") + .HasColumnName("saga_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("State") + .IsRequired() + .HasColumnType("json") + .HasColumnName("state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("version"); + + b.HasKey("Id", "SagaName") + .HasName("ix_saga_states_primary_key"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_saga_states_created_at"); + + b.ToTable("saga_states", (string)null); + }); + modelBuilder.Entity("Demo.Catalog.Entities.OrderRecord", b => { b.HasOne("Demo.Catalog.Entities.Product", "Product") diff --git a/src/Mocha/src/Demo/Demo.Catalog/Program.cs b/src/Mocha/src/Demo/Demo.Catalog/Program.cs index 9d576d64bd6..af80931fd1e 100644 --- a/src/Mocha/src/Demo/Demo.Catalog/Program.cs +++ b/src/Mocha/src/Demo/Demo.Catalog/Program.cs @@ -9,6 +9,7 @@ using Mocha; using Mocha.EntityFrameworkCore; using Mocha.Hosting; +using Mocha.Inbox; using Mocha.Outbox; using Mocha.Sagas; using Mocha.Transport.RabbitMQ; @@ -40,10 +41,15 @@ .AddSaga() .AddEntityFramework(p => { - p.AddPostgresOutbox(); p.AddPostgresSagas(); + + // dispatch + p.UsePostgresOutbox(); + + // receive p.UseResilience(); p.UseTransaction(); + p.UsePostgresInbox(); }) .AddRabbitMQ(); diff --git a/src/Mocha/src/Demo/Demo.Shipping/Data/ShippingDbContext.cs b/src/Mocha/src/Demo/Demo.Shipping/Data/ShippingDbContext.cs index 233bd1e99d7..4a5709ffe60 100644 --- a/src/Mocha/src/Demo/Demo.Shipping/Data/ShippingDbContext.cs +++ b/src/Mocha/src/Demo/Demo.Shipping/Data/ShippingDbContext.cs @@ -1,6 +1,7 @@ using Demo.Shipping.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Mocha.Inbox; using Mocha.Outbox; namespace Demo.Shipping.Data; @@ -10,12 +11,14 @@ public class ShippingDbContext(DbContextOptions options) : Db public DbSet Shipments => Set(); public DbSet ShipmentItems => Set(); public DbSet ReturnShipments => Set(); + public DbSet InboxMessages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.AddPostgresOutbox(); + modelBuilder.AddPostgresInbox(); modelBuilder.Entity(entity => { diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260104231207_Init.Designer.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260104231207_Init.Designer.cs deleted file mode 100644 index d9193644190..00000000000 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260104231207_Init.Designer.cs +++ /dev/null @@ -1,119 +0,0 @@ -// -using System; -using Demo.Shipping.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Shipping.Migrations -{ - [DbContext(typeof(ShippingDbContext))] - [Migration("20260104231207_Init")] - partial class Init - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Demo.Shipping.Entities.Shipment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Address") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Carrier") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EstimatedDelivery") - .HasColumnType("timestamp with time zone"); - - b.Property("OrderId") - .HasColumnType("uuid"); - - b.Property("ShippedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TrackingNumber") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("OrderId") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("TrackingNumber"); - - b.ToTable("Shipments"); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.ShipmentItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShipmentId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ShipmentId"); - - b.ToTable("ShipmentItems"); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.ShipmentItem", b => - { - b.HasOne("Demo.Shipping.Entities.Shipment", "Shipment") - .WithMany("Items") - .HasForeignKey("ShipmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Shipment"); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.Shipment", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260104231207_Init.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260104231207_Init.cs deleted file mode 100644 index 61c634733af..00000000000 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260104231207_Init.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Shipping.Migrations -{ - /// - public partial class Init : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Shipments", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - OrderId = table.Column(type: "uuid", nullable: false), - Address = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), - Status = table.Column(type: "integer", nullable: false), - TrackingNumber = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: true), - Carrier = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - ShippedAt = table.Column(type: "timestamp with time zone", nullable: true), - EstimatedDelivery = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - table.PrimaryKey("PK_Shipments", x => x.Id)); - - migrationBuilder.CreateTable( - name: "ShipmentItems", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - ShipmentId = table.Column(type: "uuid", nullable: false), - ProductId = table.Column(type: "uuid", nullable: false), - ProductName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Quantity = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ShipmentItems", x => x.Id); - table.ForeignKey( - name: "FK_ShipmentItems_Shipments_ShipmentId", - column: x => x.ShipmentId, - principalTable: "Shipments", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_ShipmentItems_ShipmentId", - table: "ShipmentItems", - column: "ShipmentId"); - - migrationBuilder.CreateIndex( - name: "IX_Shipments_OrderId", - table: "Shipments", - column: "OrderId", - unique: true); - - migrationBuilder.CreateIndex(name: "IX_Shipments_Status", table: "Shipments", column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_Shipments_TrackingNumber", - table: "Shipments", - column: "TrackingNumber"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ShipmentItems"); - - migrationBuilder.DropTable(name: "Shipments"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260109160418_Outbox.Designer.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260109160418_Outbox.Designer.cs deleted file mode 100644 index 53d1421290c..00000000000 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260109160418_Outbox.Designer.cs +++ /dev/null @@ -1,150 +0,0 @@ -// -using System; -using System.Text.Json; -using Demo.Shipping.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Demo.Shipping.Migrations -{ - [DbContext(typeof(ShippingDbContext))] - [Migration("20260109160418_Outbox")] - partial class Outbox - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt") - .IsDescending(); - - b.HasIndex("TimesSent"); - - b.ToTable("outbox_messages", (string)null); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.Shipment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Address") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Carrier") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EstimatedDelivery") - .HasColumnType("timestamp with time zone"); - - b.Property("OrderId") - .HasColumnType("uuid"); - - b.Property("ShippedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TrackingNumber") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("OrderId") - .IsUnique(); - - b.HasIndex("Status"); - - b.HasIndex("TrackingNumber"); - - b.ToTable("Shipments"); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.ShipmentItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Quantity") - .HasColumnType("integer"); - - b.Property("ShipmentId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ShipmentId"); - - b.ToTable("ShipmentItems"); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.ShipmentItem", b => - { - b.HasOne("Demo.Shipping.Entities.Shipment", "Shipment") - .WithMany("Items") - .HasForeignKey("ShipmentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Shipment"); - }); - - modelBuilder.Entity("Demo.Shipping.Entities.Shipment", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260109160418_Outbox.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260109160418_Outbox.cs deleted file mode 100644 index 99a53496a25..00000000000 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260109160418_Outbox.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Text.Json; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Shipping.Migrations -{ - /// - public partial class Outbox : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "outbox_messages", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false), - envelope = table.Column(type: "json", nullable: false), - times_sent = table.Column(type: "integer", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - table.PrimaryKey("PK_outbox_messages", x => x.id)); - - migrationBuilder.CreateIndex( - name: "IX_outbox_messages_created_at", - table: "outbox_messages", - column: "created_at", - descending: new bool[0]); - - migrationBuilder.CreateIndex( - name: "IX_outbox_messages_times_sent", - table: "outbox_messages", - column: "times_sent"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "outbox_messages"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260111233128_AddRefundSaga.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260111233128_AddRefundSaga.cs deleted file mode 100644 index 1a61e6c9118..00000000000 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260111233128_AddRefundSaga.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Demo.Shipping.Migrations -{ - /// - public partial class AddRefundSaga : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropPrimaryKey(name: "PK_outbox_messages", table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "IX_outbox_messages_times_sent", - newName: "ix_outbox_messages_times_sent", - table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "IX_outbox_messages_created_at", - newName: "ix_outbox_messages_created_at", - table: "outbox_messages"); - - migrationBuilder.AddPrimaryKey( - name: "ix_outbox_messages_primary_key", - table: "outbox_messages", - column: "id"); - - migrationBuilder.CreateTable( - name: "ReturnShipments", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - OrderId = table.Column(type: "uuid", nullable: false), - OriginalShipmentId = table.Column(type: "uuid", nullable: false), - CustomerAddress = table.Column( - type: "character varying(500)", - maxLength: 500, - nullable: false), - CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - TrackingNumber = table.Column( - type: "character varying(100)", - maxLength: 100, - nullable: true), - LabelUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), - Status = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - ReceivedAt = table.Column(type: "timestamp with time zone", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ReturnShipments", x => x.Id); - table.ForeignKey( - name: "FK_ReturnShipments_Shipments_OriginalShipmentId", - column: x => x.OriginalShipmentId, - principalTable: "Shipments", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_ReturnShipments_OrderId", - table: "ReturnShipments", - column: "OrderId"); - - migrationBuilder.CreateIndex( - name: "IX_ReturnShipments_OriginalShipmentId", - table: "ReturnShipments", - column: "OriginalShipmentId"); - - migrationBuilder.CreateIndex(name: "IX_ReturnShipments_Status", table: "ReturnShipments", column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_ReturnShipments_TrackingNumber", - table: "ReturnShipments", - column: "TrackingNumber"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ReturnShipments"); - - migrationBuilder.DropPrimaryKey(name: "ix_outbox_messages_primary_key", table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "ix_outbox_messages_times_sent", - newName: "IX_outbox_messages_times_sent", - table: "outbox_messages"); - - migrationBuilder.RenameIndex( - name: "ix_outbox_messages_created_at", - newName: "IX_outbox_messages_created_at", - table: "outbox_messages"); - - migrationBuilder.AddPrimaryKey(name: "PK_outbox_messages", table: "outbox_messages", column: "id"); - } - } -} diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260111233128_AddRefundSaga.Designer.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260309003638_Init.Designer.cs similarity index 79% rename from src/Mocha/src/Demo/Demo.Shipping/Migrations/20260111233128_AddRefundSaga.Designer.cs rename to src/Mocha/src/Demo/Demo.Shipping/Migrations/20260309003638_Init.Designer.cs index 485fd7dc602..6cf36c13d50 100644 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260111233128_AddRefundSaga.Designer.cs +++ b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260309003638_Init.Designer.cs @@ -10,11 +10,11 @@ #nullable disable -namespace Demo.Shipping.Migrations +namespace HotChocolate.Demo.Shipping.Migrations { [DbContext(typeof(ShippingDbContext))] - [Migration("20260111233128_AddRefundSaga")] - partial class AddRefundSaga + [Migration("20260309003638_Init")] + partial class Init { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -26,45 +26,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - modelBuilder.Entity("Demo.Shipping.Entities.ReturnShipment", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Amount") + .HasColumnType("numeric"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -88,6 +58,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("OriginalShipmentId") .HasColumnType("uuid"); + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Reason") + .HasColumnType("text"); + b.Property("ReceivedAt") .HasColumnType("timestamp with time zone"); @@ -184,6 +163,72 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ShipmentItems"); }); + modelBuilder.Entity("Mocha.Inbox.InboxMessage", b => + { + b.Property("MessageId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_id"); + + b.Property("ConsumerType") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("consumer_type"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_type"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("MessageId", "ConsumerType") + .HasName("ix_inbox_messages_primary_key"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_inbox_messages_processed_at"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Envelope") + .IsRequired() + .HasColumnType("json") + .HasColumnName("envelope"); + + b.Property("TimesSent") + .HasColumnType("integer") + .HasColumnName("times_sent"); + + b.HasKey("Id") + .HasName("ix_outbox_messages_primary_key"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("ix_outbox_messages_created_at"); + + b.HasIndex("TimesSent") + .HasDatabaseName("ix_outbox_messages_times_sent"); + + b.ToTable("outbox_messages", (string)null); + }); + modelBuilder.Entity("Demo.Shipping.Entities.ReturnShipment", b => { b.HasOne("Demo.Shipping.Entities.Shipment", "OriginalShipment") diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260309003638_Init.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260309003638_Init.cs new file mode 100644 index 00000000000..fac50f4acd4 --- /dev/null +++ b/src/Mocha/src/Demo/Demo.Shipping/Migrations/20260309003638_Init.cs @@ -0,0 +1,190 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HotChocolate.Demo.Shipping.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + message_id = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + consumer_type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + message_type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("ix_inbox_messages_primary_key", x => new { x.message_id, x.consumer_type }); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + envelope = table.Column(type: "json", nullable: false), + times_sent = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("ix_outbox_messages_primary_key", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "Shipments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + Address = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "integer", nullable: false), + TrackingNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Carrier = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ShippedAt = table.Column(type: "timestamp with time zone", nullable: true), + EstimatedDelivery = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Shipments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ReturnShipments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + OriginalShipmentId = table.Column(type: "uuid", nullable: false), + CustomerAddress = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CustomerId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + TrackingNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + LabelUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: true), + ProductId = table.Column(type: "uuid", nullable: false), + Quantity = table.Column(type: "integer", nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Reason = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReturnShipments", x => x.Id); + table.ForeignKey( + name: "FK_ReturnShipments_Shipments_OriginalShipmentId", + column: x => x.OriginalShipmentId, + principalTable: "Shipments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ShipmentItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ShipmentId = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + ProductName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Quantity = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShipmentItems", x => x.Id); + table.ForeignKey( + name: "FK_ShipmentItems_Shipments_ShipmentId", + column: x => x.ShipmentId, + principalTable: "Shipments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_inbox_messages_processed_at", + table: "inbox_messages", + column: "processed_at"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_created_at", + table: "outbox_messages", + column: "created_at", + descending: new bool[0]); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_times_sent", + table: "outbox_messages", + column: "times_sent"); + + migrationBuilder.CreateIndex( + name: "IX_ReturnShipments_OrderId", + table: "ReturnShipments", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_ReturnShipments_OriginalShipmentId", + table: "ReturnShipments", + column: "OriginalShipmentId"); + + migrationBuilder.CreateIndex( + name: "IX_ReturnShipments_Status", + table: "ReturnShipments", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ReturnShipments_TrackingNumber", + table: "ReturnShipments", + column: "TrackingNumber"); + + migrationBuilder.CreateIndex( + name: "IX_ShipmentItems_ShipmentId", + table: "ShipmentItems", + column: "ShipmentId"); + + migrationBuilder.CreateIndex( + name: "IX_Shipments_OrderId", + table: "Shipments", + column: "OrderId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Shipments_Status", + table: "Shipments", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_Shipments_TrackingNumber", + table: "Shipments", + column: "TrackingNumber"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + + migrationBuilder.DropTable( + name: "ReturnShipments"); + + migrationBuilder.DropTable( + name: "ShipmentItems"); + + migrationBuilder.DropTable( + name: "Shipments"); + } + } +} diff --git a/src/Mocha/src/Demo/Demo.Shipping/Migrations/ShippingDbContextModelSnapshot.cs b/src/Mocha/src/Demo/Demo.Shipping/Migrations/ShippingDbContextModelSnapshot.cs index 44ccc8ed3a1..14e74756819 100644 --- a/src/Mocha/src/Demo/Demo.Shipping/Migrations/ShippingDbContextModelSnapshot.cs +++ b/src/Mocha/src/Demo/Demo.Shipping/Migrations/ShippingDbContextModelSnapshot.cs @@ -9,7 +9,7 @@ #nullable disable -namespace Demo.Shipping.Migrations +namespace HotChocolate.Demo.Shipping.Migrations { [DbContext(typeof(ShippingDbContext))] partial class ShippingDbContextModelSnapshot : ModelSnapshot @@ -23,45 +23,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Envelope") - .IsRequired() - .HasColumnType("json") - .HasColumnName("envelope"); - - b.Property("TimesSent") - .HasColumnType("integer") - .HasColumnName("times_sent"); - - b.HasKey("Id") - .HasName("ix_outbox_messages_primary_key"); - - b.HasIndex("CreatedAt") - .IsDescending() - .HasDatabaseName("ix_outbox_messages_created_at"); - - b.HasIndex("TimesSent") - .HasDatabaseName("ix_outbox_messages_times_sent"); - - b.ToTable("outbox_messages", (string)null); - }); - modelBuilder.Entity("Demo.Shipping.Entities.ReturnShipment", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Amount") + .HasColumnType("numeric"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -85,6 +55,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OriginalShipmentId") .HasColumnType("uuid"); + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Reason") + .HasColumnType("text"); + b.Property("ReceivedAt") .HasColumnType("timestamp with time zone"); @@ -181,6 +160,72 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ShipmentItems"); }); + modelBuilder.Entity("Mocha.Inbox.InboxMessage", b => + { + b.Property("MessageId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_id"); + + b.Property("ConsumerType") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("consumer_type"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("message_type"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("MessageId", "ConsumerType") + .HasName("ix_inbox_messages_primary_key"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_inbox_messages_processed_at"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("Mocha.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Envelope") + .IsRequired() + .HasColumnType("json") + .HasColumnName("envelope"); + + b.Property("TimesSent") + .HasColumnType("integer") + .HasColumnName("times_sent"); + + b.HasKey("Id") + .HasName("ix_outbox_messages_primary_key"); + + b.HasIndex("CreatedAt") + .IsDescending() + .HasDatabaseName("ix_outbox_messages_created_at"); + + b.HasIndex("TimesSent") + .HasDatabaseName("ix_outbox_messages_times_sent"); + + b.ToTable("outbox_messages", (string)null); + }); + modelBuilder.Entity("Demo.Shipping.Entities.ReturnShipment", b => { b.HasOne("Demo.Shipping.Entities.Shipment", "OriginalShipment") diff --git a/src/Mocha/src/Demo/Demo.Shipping/Program.cs b/src/Mocha/src/Demo/Demo.Shipping/Program.cs index 5e996b11b06..03d3a78f4f5 100644 --- a/src/Mocha/src/Demo/Demo.Shipping/Program.cs +++ b/src/Mocha/src/Demo/Demo.Shipping/Program.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Mocha; using Mocha.EntityFrameworkCore; +using Mocha.Inbox; using Mocha.Outbox; using Mocha.Transport.RabbitMQ; @@ -28,7 +29,10 @@ .AddRequestHandler() .AddRequestHandler() .AddEntityFramework(p => - p.AddPostgresOutbox()) + { + p.UsePostgresOutbox(); + p.UsePostgresInbox(); + }) .AddRabbitMQ(); var app = builder.Build(); diff --git a/src/Mocha/src/Examples/Reliability/OutboxInbox/OutboxInbox.cs b/src/Mocha/src/Examples/Reliability/OutboxInbox/OutboxInbox.cs index eeca9481c9a..dcbcee4b1e8 100644 --- a/src/Mocha/src/Examples/Reliability/OutboxInbox/OutboxInbox.cs +++ b/src/Mocha/src/Examples/Reliability/OutboxInbox/OutboxInbox.cs @@ -3,11 +3,13 @@ // #:package Mocha.Transport.RabbitMQ@1.0.0-preview.* // #:package Mocha.EntityFrameworkCore.Postgres@1.0.0-preview.* // #:package Mocha.Outbox@1.0.0-preview.* +// #:package Mocha.Inbox@1.0.0-preview.* // $ dotnet run OutboxInbox.cs using Microsoft.EntityFrameworkCore; using Mocha; using Mocha.EntityFrameworkCore; +using Mocha.Inbox; using Mocha.Outbox; using Mocha.Transport.RabbitMQ; using RabbitMQ.Client; @@ -38,7 +40,11 @@ { // Persist outbound messages to the outbox table in the same transaction // as your business data. A background processor dispatches them after commit. - p.AddPostgresOutbox(); + p.UsePostgresOutbox(); + + // Record processed message IDs in the inbox table so that duplicate + // deliveries are silently skipped, guaranteeing exactly-once processing. + p.UsePostgresInbox(); // Wrap each consumer invocation in a database transaction. // The transaction commits on success and rolls back on failure. @@ -79,6 +85,9 @@ public class AppDbContext(DbContextOptions options) : DbContext(op // Outbox table — required for the transactional outbox public DbSet OutboxMessages => Set(); + // Inbox table — required for idempotent message consumption + public DbSet InboxMessages => Set(); + // Your business data public DbSet Orders => Set(); @@ -88,6 +97,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Configures the OutboxMessages table with Postgres-optimized column types and indexes modelBuilder.AddPostgresOutbox(); + + // Configures the InboxMessages table for deduplication of incoming messages + modelBuilder.AddPostgresInbox(); } } @@ -101,8 +113,10 @@ public class Order // --- Handlers --- // With UseTransaction() active, this handler runs inside a database transaction. -// With AddPostgresOutbox() active, calls to bus.PublishAsync() write to the outbox +// With UsePostgresOutbox() active, calls to bus.PublishAsync() write to the outbox // table rather than directly to RabbitMQ — within the same transaction. +// With UsePostgresInbox() active, the inbox middleware checks whether this message +// has already been processed before invoking the handler, preventing duplicates. // After db.SaveChangesAsync() commits the transaction, the outbox processor // picks up the pending messages and dispatches them to RabbitMQ. public class OrderPlacedHandler(AppDbContext db, IMessageBus bus) @@ -122,7 +136,7 @@ public async ValueTask HandleAsync( db.Orders.Add(order); // This write goes to the outbox table (not directly to RabbitMQ) because - // AddPostgresOutbox() intercepts IMessageBus calls inside a transaction. + // UsePostgresOutbox() intercepts IMessageBus calls inside a transaction. await bus.PublishAsync( new InvoiceCreated(Guid.NewGuid(), order.Id, order.Amount), cancellationToken); diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/InboxMessage.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/InboxMessage.cs new file mode 100644 index 00000000000..8cfc1f04aad --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/InboxMessage.cs @@ -0,0 +1,37 @@ +namespace Mocha.Inbox; + +/// +/// Represents a processed message recorded in the inbox to prevent duplicate processing. +/// +/// The unique message identifier. +/// The consumer type name that processed this message. +/// The message type for diagnostics. +/// The UTC timestamp when this message was processed. +public sealed class InboxMessage(string messageId, string consumerType, string messageType, DateTimeOffset processedAt) +{ + /// + /// Gets the unique message identifier. + /// + public string MessageId { get; private set; } = messageId; + + /// + /// Gets the consumer type name that processed this message. + /// + public string ConsumerType { get; private set; } = consumerType; + + /// + /// Gets the message type for diagnostics. + /// + public string MessageType { get; private set; } = messageType; + + /// + /// Gets the UTC timestamp when this message was processed. + /// + public DateTimeOffset ProcessedAt { get; private set; } = processedAt; + + // needed for EF Core + // ReSharper disable once UnusedMember.Local + private InboxMessage() : this(default!, default!, default!, default) + { + } +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/InboxServiceCollectionExtensions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/InboxServiceCollectionExtensions.cs new file mode 100644 index 00000000000..57cbf939cb0 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/InboxServiceCollectionExtensions.cs @@ -0,0 +1,153 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Mocha.EntityFrameworkCore; +using Mocha.EntityFrameworkCore.Postgres; + +namespace Mocha.Inbox; + +/// +/// Provides extension methods on for registering +/// the Postgres inbox infrastructure including the cleanup worker and message deduplication. +/// +public static class InboxServiceCollectionExtensions +{ + /// + /// Registers the full Postgres inbox pipeline: table info discovery from the EF Core model, + /// pre-built SQL queries, a hosted background cleanup worker, and a scoped + /// backed by direct Npgsql commands. + /// + /// The Entity Framework Core builder to configure. + /// An optional action to configure . + /// The same instance for chaining. + public static IEntityFrameworkCoreBuilder UsePostgresInbox( + this IEntityFrameworkCoreBuilder builder, + Action? configure = null) + { + var contextType = builder.ContextType; + + builder.HostBuilder.UseInboxCore(); + + if (configure is not null) + { + builder.Services.Configure(configure); + } + else + { + builder.Services.Configure(static _ => { }); + } + + // Configure PostgresTableInfo for Inbox + builder + .Services.AddOptions(builder.Name) + .Configure((options, sp) => + { + using var scope = sp.CreateScope(); + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); + var model = dbContext.Model; + + ConfigureInboxTableInfo(options.Inbox, model); + }); + + // Configure PostgresMessageInboxOptions with pre-built queries + builder + .Services.AddOptions(builder.Name) + .Configure>((options, sp, tableInfoMonitor) => + { + using var scope = sp.CreateScope(); + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); + options.ConnectionString = + dbContext.Database.GetConnectionString() ?? + throw new InvalidOperationException( + $"Could not read the connection string from {contextType.Name}"); + var tableInfo = tableInfoMonitor.Get(builder.Name); + options.Queries = PostgresMessageInboxQueries.From(tableInfo.Inbox); + }); + + builder.Services.AddSingleton(sp => + new MessageBusInboxWorker( + sp.GetRequiredService>(), + sp, + sp.GetService() ?? TimeProvider.System, + sp.GetRequiredService>(), + sp.GetRequiredService>())); + + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + builder.Services.TryAddScoped(sp => + PostgresMessageInbox.Create(contextType, builder.Name, sp)); + + return builder; + } + + private static void ConfigureInboxTableInfo(InboxTableInfo inbox, IModel model) + { + var inboxEntity = model.FindEntityType(typeof(InboxMessage)); + if (inboxEntity is null) + { + return; + } + + var tableName = inboxEntity.GetTableName(); + var schema = inboxEntity.GetSchema(); + + if (tableName is not null) + { + inbox.Table = tableName; + } + + if (schema is not null) + { + inbox.Schema = schema; + } + + var storeObject = StoreObjectIdentifier.Create(inboxEntity, StoreObjectType.Table); + if (storeObject is null) + { + return; + } + + var messageIdProperty = inboxEntity.FindProperty(nameof(InboxMessage.MessageId)); + if (messageIdProperty is not null) + { + var columnName = messageIdProperty.GetColumnName(storeObject.Value); + if (columnName is not null) + { + inbox.MessageId = columnName; + } + } + + var consumerTypeProperty = inboxEntity.FindProperty(nameof(InboxMessage.ConsumerType)); + if (consumerTypeProperty is not null) + { + var columnName = consumerTypeProperty.GetColumnName(storeObject.Value); + if (columnName is not null) + { + inbox.ConsumerType = columnName; + } + } + + var messageTypeProperty = inboxEntity.FindProperty(nameof(InboxMessage.MessageType)); + if (messageTypeProperty is not null) + { + var columnName = messageTypeProperty.GetColumnName(storeObject.Value); + if (columnName is not null) + { + inbox.MessageType = columnName; + } + } + + var processedAtProperty = inboxEntity.FindProperty(nameof(InboxMessage.ProcessedAt)); + if (processedAtProperty is not null) + { + var columnName = processedAtProperty.GetColumnName(storeObject.Value); + if (columnName is not null) + { + inbox.ProcessedAt = columnName; + } + } + } +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresInboxMessageEntityConfiguration.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresInboxMessageEntityConfiguration.cs new file mode 100644 index 00000000000..2bc02e23a59 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresInboxMessageEntityConfiguration.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mocha.EntityFrameworkCore.Postgres; + +namespace Mocha.Inbox; + +/// +/// Configures the EF Core entity mapping for using default Postgres +/// table and column names from . +/// +internal sealed class PostgresInboxMessageEntityConfiguration + : IEntityTypeConfiguration +{ + // Use default values from InboxTableInfo as the source of truth + private static readonly InboxTableInfo s_defaults = new(); + + /// + /// Gets the shared singleton instance of the inbox message entity configuration. + /// + public static PostgresInboxMessageEntityConfiguration Instance { get; } = new(); + + /// + /// Configures the inbox message entity with table name, primary key, indexes, and column mappings. + /// + /// The entity type builder for . + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(s_defaults.Table); + + builder.HasKey(e => new { e.MessageId, e.ConsumerType }).HasName(s_defaults.IxPrimaryKey); + + builder.HasIndex(e => e.ProcessedAt) + .HasDatabaseName(s_defaults.IxProcessedAt); + + builder.Property(e => e.MessageId) + .HasColumnName(s_defaults.MessageId) + .HasMaxLength(512) + .ValueGeneratedNever(); + + builder.Property(e => e.ConsumerType) + .HasColumnName(s_defaults.ConsumerType) + .HasMaxLength(512) + .ValueGeneratedNever(); + + builder.Property(e => e.MessageType) + .HasColumnName(s_defaults.MessageType) + .HasMaxLength(512); + + builder.Property(e => e.ProcessedAt) + .HasColumnName(s_defaults.ProcessedAt) + .HasDefaultValueSql("NOW()"); + } +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresInboxPersistenceModelBuilderExtensions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresInboxPersistenceModelBuilderExtensions.cs new file mode 100644 index 00000000000..fd332ef1ef7 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresInboxPersistenceModelBuilderExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace Mocha.Inbox; + +/// +/// Provides extension methods on for applying the Postgres inbox +/// entity configuration to the EF Core model. +/// +public static class PostgresInboxPersistenceModelBuilderExtensions +{ + /// + /// Applies the entity type configuration to the model, + /// mapping it to the Postgres inbox table with default column names and indexes. + /// + /// The EF Core model builder to configure. + public static void AddPostgresInbox(this ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(PostgresInboxMessageEntityConfiguration.Instance); + } +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInbox.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInbox.cs new file mode 100644 index 00000000000..1a76d711452 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInbox.cs @@ -0,0 +1,247 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Mocha.Middlewares; +using Npgsql; + +namespace Mocha.Inbox; + +/// +/// Implements for Postgres by recording processed message identifiers +/// in the inbox table using raw SQL through the DbContext Npgsql connection. +/// +/// +/// When a database transaction is active on the DbContext, the inbox operations participate in +/// that transaction to ensure atomicity with message processing. +/// A serializes access to the shared connection to prevent concurrent +/// command execution on the same Npgsql connection. +/// +internal sealed class PostgresMessageInbox : IMessageInbox, IDisposable +{ + private readonly DbContext _dbContext; + private readonly NpgsqlConnection _connection; + private readonly PostgresMessageInboxQueries _queries; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + /// Creates a new using the provided DbContext connection + /// and pre-built SQL queries. + /// + /// The DbContext whose underlying Npgsql connection is used for inbox operations. + /// The Npgsql connection to use for inbox queries. + /// The pre-built SQL queries for inbox operations. + /// The time provider used for computing cleanup cutoff timestamps. + internal PostgresMessageInbox( + DbContext dbContext, + NpgsqlConnection connection, + PostgresMessageInboxQueries queries, + TimeProvider timeProvider) + { + _dbContext = dbContext; + _connection = connection; + _queries = queries; + _timeProvider = timeProvider; + } + + /// + public async ValueTask ExistsAsync( + string messageId, + string consumerType, + CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + await EnsureConnectionOpenAsync(cancellationToken); + + await using var command = new NpgsqlCommand(_queries.Exists, _connection); + + var activeTransaction = _dbContext.GetActiveTransaction(); + + if (activeTransaction is not null) + { + command.Transaction = activeTransaction; + } + + command.Parameters.AddWithValue("message_id", messageId); + command.Parameters.AddWithValue("consumer_type", consumerType); + + await command.PrepareAsync(cancellationToken); + + var result = await command.ExecuteScalarAsync(cancellationToken); + + return result is not null && (bool)result; + } + finally + { + _semaphore.Release(); + } + } + + /// + public async ValueTask TryClaimAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + await EnsureConnectionOpenAsync(cancellationToken); + + await using var command = new NpgsqlCommand(_queries.TryClaim, _connection); + + var activeTransaction = _dbContext.GetActiveTransaction(); + + if (activeTransaction is not null) + { + command.Transaction = activeTransaction; + } + + command.Parameters.AddWithValue("message_id", envelope.MessageId ?? string.Empty); + command.Parameters.AddWithValue("consumer_type", consumerType); + command.Parameters.AddWithValue("message_type", envelope.MessageType ?? string.Empty); + + await command.PrepareAsync(cancellationToken); + + var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken); + + return rowsAffected > 0; + } + finally + { + _semaphore.Release(); + } + } + + /// + public async ValueTask RecordAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + await EnsureConnectionOpenAsync(cancellationToken); + + await using var command = new NpgsqlCommand(_queries.Insert, _connection); + + var activeTransaction = _dbContext.GetActiveTransaction(); + + if (activeTransaction is not null) + { + command.Transaction = activeTransaction; + } + + command.Parameters.AddWithValue("message_id", envelope.MessageId ?? string.Empty); + command.Parameters.AddWithValue("consumer_type", consumerType); + command.Parameters.AddWithValue("message_type", envelope.MessageType ?? string.Empty); + + await command.PrepareAsync(cancellationToken); + await command.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + _semaphore.Release(); + } + } + + /// + public async ValueTask CleanupAsync( + TimeSpan maxAge, + CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + await EnsureConnectionOpenAsync(cancellationToken); + + await using var command = new NpgsqlCommand(_queries.Cleanup, _connection); + + var activeTransaction = _dbContext.GetActiveTransaction(); + + if (activeTransaction is not null) + { + command.Transaction = activeTransaction; + } + + command.Parameters.AddWithValue("cutoff", _timeProvider.GetUtcNow().UtcDateTime - maxAge); + + await command.PrepareAsync(cancellationToken); + + return await command.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Ensures the underlying Npgsql connection is open before executing a command. + /// If the connection is in a state, + /// it is closed first and then re-opened to recover from transient failures. + /// + /// A token to observe for cancellation. + private async ValueTask EnsureConnectionOpenAsync(CancellationToken cancellationToken) + { + if (_connection.State == System.Data.ConnectionState.Broken) + { + await _connection.CloseAsync(); + } + + if (_connection.State != System.Data.ConnectionState.Open) + { + await _connection.OpenAsync(cancellationToken); + } + } + + /// + /// Releases the semaphore used for connection serialization. + /// + public void Dispose() + { + _semaphore.Dispose(); + } + + /// + /// Creates a new by resolving the DbContext and named inbox + /// options from the scoped service provider. + /// + /// The of the DbContext to resolve. + /// The named options key used to retrieve . + /// The scoped service provider used to resolve dependencies. + /// A new configured for the specified DbContext. + public static PostgresMessageInbox Create(Type contextType, string optionsName, IServiceProvider services) + { + var dbContext = (DbContext)services.GetRequiredService(contextType); + var connection = dbContext.Database.GetDbConnection() as NpgsqlConnection ?? + throw new InvalidOperationException("Not an Npgsql connection."); + + var optionsMonitor = services.GetRequiredService>(); + var options = optionsMonitor.Get(optionsName); + var timeProvider = services.GetService() ?? TimeProvider.System; + + return new PostgresMessageInbox(dbContext, connection, options.Queries, timeProvider); + } +} + +file static class Extensions +{ + /// + /// Retrieves the active from the DbContext, if one exists. + /// + /// The active transaction, or null if no transaction is in progress. + public static NpgsqlTransaction? GetActiveTransaction(this DbContext context) + => context + .Database + .CurrentTransaction + ?.GetDbTransaction() as NpgsqlTransaction; +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInboxOptions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInboxOptions.cs new file mode 100644 index 00000000000..580f0ba10d6 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInboxOptions.cs @@ -0,0 +1,18 @@ +namespace Mocha.Inbox; + +/// +/// Configuration options for the Postgres message inbox, including pre-built SQL queries +/// and the connection string used by the inbox cleanup worker. +/// +internal sealed class PostgresMessageInboxOptions +{ + /// + /// Gets or sets the pre-built SQL queries used for inbox exists, insert, and cleanup operations. + /// + public PostgresMessageInboxQueries Queries { get; set; } = null!; + + /// + /// Gets or sets the Postgres connection string used by the inbox cleanup worker. + /// + public string ConnectionString { get; set; } = null!; +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInboxQueries.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInboxQueries.cs new file mode 100644 index 00000000000..3ee1f420b8d --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Inbox/PostgresMessageInboxQueries.cs @@ -0,0 +1,72 @@ +using Mocha.EntityFrameworkCore.Postgres; + +namespace Mocha.Inbox; + +/// +/// Holds pre-built SQL query strings for Postgres inbox operations, generated from +/// column and table metadata. +/// +internal sealed class PostgresMessageInboxQueries +{ + /// + /// Gets or sets the SQL query to check if a message has already been processed by a specific consumer type. + /// + public string Exists { get; set; } = null!; + + /// + /// Gets or sets the SQL statement to insert a processed message record into the inbox table. + /// Uses ON CONFLICT DO NOTHING for idempotent inserts. + /// + public string Insert { get; set; } = null!; + + /// + /// Gets or sets the SQL statement to atomically attempt to claim a message by inserting it. + /// Returns the number of affected rows (1 if claimed, 0 if already claimed). + /// + public string TryClaim { get; set; } = null!; + + /// + /// Gets or sets the SQL statement to delete old processed inbox messages. + /// + public string Cleanup { get; set; } = null!; + + /// + /// Creates a new instance with SQL queries built from the provided table metadata. + /// + /// The inbox table info containing column and table names. + /// A fully initialized instance. + public static PostgresMessageInboxQueries From(InboxTableInfo table) + { + return new PostgresMessageInboxQueries + { + Exists = + $""" + SELECT EXISTS( + SELECT 1 FROM {table.QualifiedTableName} + WHERE "{table.MessageId}" = @message_id AND "{table.ConsumerType}" = @consumer_type + ) + """, + Insert = + $""" + INSERT INTO {table.QualifiedTableName} ("{table.MessageId}", "{table.ConsumerType}", "{table.MessageType}", "{table.ProcessedAt}") + VALUES (@message_id, @consumer_type, @message_type, NOW()) + ON CONFLICT ("{table.MessageId}", "{table.ConsumerType}") DO NOTHING + """, + TryClaim = + $""" + INSERT INTO {table.QualifiedTableName} ("{table.MessageId}", "{table.ConsumerType}", "{table.MessageType}", "{table.ProcessedAt}") + VALUES (@message_id, @consumer_type, @message_type, NOW()) + ON CONFLICT ("{table.MessageId}", "{table.ConsumerType}") DO NOTHING + """, + Cleanup = + $""" + DELETE FROM {table.QualifiedTableName} + WHERE ctid IN ( + SELECT ctid FROM {table.QualifiedTableName} + WHERE "{table.ProcessedAt}" < @cutoff + LIMIT 1000 + ) + """ + }; + } +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/InboxTableInfo.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/InboxTableInfo.cs new file mode 100644 index 00000000000..cde1c52bf13 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/InboxTableInfo.cs @@ -0,0 +1,53 @@ +namespace Mocha.EntityFrameworkCore.Postgres; + +/// +/// Table and column information for the inbox messages table. +/// +public sealed class InboxTableInfo +{ + /// + /// Gets or sets the database schema for the inbox table. Defaults to "public". + /// + public string Schema { get; set; } = "public"; + + /// + /// Gets or sets the table name for inbox messages. Defaults to "inbox_messages". + /// + public string Table { get; set; } = "inbox_messages"; + + /// + /// Gets or sets the column name for the message identifier. Defaults to "message_id". + /// + public string MessageId { get; set; } = "message_id"; + + /// + /// Gets or sets the column name for the consumer type. Defaults to "consumer_type". + /// + public string ConsumerType { get; set; } = "consumer_type"; + + /// + /// Gets or sets the column name for the message type. Defaults to "message_type". + /// + public string MessageType { get; set; } = "message_type"; + + /// + /// Gets or sets the column name for the processed-at timestamp. Defaults to "processed_at". + /// + public string ProcessedAt { get; set; } = "processed_at"; + + /// + /// Gets or sets the name of the primary key index. Defaults to "ix_inbox_messages_primary_key". + /// + public string IxPrimaryKey { get; set; } = "ix_inbox_messages_primary_key"; + + /// + /// Gets or sets the name of the processed-at index used for cleanup ordering. Defaults to "ix_inbox_messages_processed_at". + /// + public string IxProcessedAt { get; set; } = "ix_inbox_messages_processed_at"; + + /// + /// Gets the fully qualified table name including schema if not public. + /// + public string QualifiedTableName + => string.IsNullOrEmpty(Schema) || Schema == "public" ? $"\"{Table}\"" : $"\"{Schema}\".\"{Table}\""; +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Mocha.EntityFrameworkCore.Postgres.csproj b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Mocha.EntityFrameworkCore.Postgres.csproj index 4c54a065d72..2022bdad08d 100644 --- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Mocha.EntityFrameworkCore.Postgres.csproj +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Mocha.EntityFrameworkCore.Postgres.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs index fa98a90c624..33ee57b7d33 100644 --- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs @@ -21,41 +21,40 @@ public static class OutboxServiceCollectionExtensions /// backed by direct Npgsql inserts. /// /// - /// This method also calls + /// This method also calls /// to register the EF Core interceptors that signal the processor on save and commit. /// /// The Entity Framework Core builder to configure. /// The same instance for chaining. - public static IEntityFrameworkCoreBuilder AddPostgresOutbox(this IEntityFrameworkCoreBuilder builder) + public static IEntityFrameworkCoreBuilder UsePostgresOutbox(this IEntityFrameworkCoreBuilder builder) { var contextType = builder.ContextType; builder .Services.AddOptions(builder.Name) - .Configure( - (options, sp) => - { - using var scope = sp.CreateScope(); - var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); - var model = dbContext.Model; + .Configure((options, sp) => + { + using var scope = sp.CreateScope(); + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); + var model = dbContext.Model; - ConfigureOutboxTableInfo(options.Outbox, model); - }); + ConfigureOutboxTableInfo(options.Outbox, model); + }); builder .Services.AddOptions(builder.Name) - .Configure>( - (options, postgresOptions, tableInfoMonitor) => - { - using var scope = postgresOptions.CreateScope(); - var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); - options.ConnectionString = - dbContext.Database.GetConnectionString() - ?? throw new InvalidOperationException( - $"Could not read the connection string from {contextType.Name}"); - var tableInfo = tableInfoMonitor.Get(builder.Name); - options.Queries = PostgresMessageOutboxQueries.From(tableInfo.Outbox); - }); + .Configure>((options, postgresOptions, + tableInfoMonitor) => + { + using var scope = postgresOptions.CreateScope(); + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(contextType); + options.ConnectionString = + dbContext.Database.GetConnectionString() ?? + throw new InvalidOperationException( + $"Could not read the connection string from {contextType.Name}"); + var tableInfo = tableInfoMonitor.Get(builder.Name); + options.Queries = PostgresMessageOutboxQueries.From(tableInfo.Outbox); + }); builder.Services.AddSingleton(sp => { @@ -83,7 +82,7 @@ public static IEntityFrameworkCoreBuilder AddPostgresOutbox(this IEntityFramewor PostgresMessageOutbox.Create(contextType, builder.Name, sp) ); - builder.AddOutboxCore(); + builder.UseOutboxCore(); return builder; } diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/OutboxTableInfo.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/OutboxTableInfo.cs new file mode 100644 index 00000000000..cadc8f37434 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/OutboxTableInfo.cs @@ -0,0 +1,58 @@ +namespace Mocha.EntityFrameworkCore.Postgres; + +/// +/// Table and column information for the outbox messages table. +/// +public sealed class OutboxTableInfo +{ + /// + /// Gets or sets the database schema for the outbox table. Defaults to "public". + /// + public string Schema { get; set; } = "public"; + + /// + /// Gets or sets the table name for outbox messages. Defaults to "outbox_messages". + /// + public string Table { get; set; } = "outbox_messages"; + + /// + /// Gets or sets the column name for the outbox message identifier. Defaults to "id". + /// + public string Id { get; set; } = "id"; + + /// + /// Gets or sets the column name for the serialized message envelope. Defaults to "envelope". + /// + public string Envelope { get; set; } = "envelope"; + + /// + /// Gets or sets the column name tracking how many times the message has been dispatched. Defaults to "times_sent". + /// + public string TimesSent { get; set; } = "times_sent"; + + /// + /// Gets or sets the column name for the message creation timestamp. Defaults to "created_at". + /// + public string CreatedAt { get; set; } = "created_at"; + + /// + /// Gets or sets the name of the primary key index. Defaults to "ix_outbox_messages_primary_key". + /// + public string IxPrimaryKey { get; set; } = "ix_outbox_messages_primary_key"; + + /// + /// Gets or sets the name of the created-at index used for ordering outbox processing. Defaults to "ix_outbox_messages_created_at". + /// + public string IxCreatedAt { get; set; } = "ix_outbox_messages_created_at"; + + /// + /// Gets or sets the name of the times-sent index used for retry filtering. Defaults to "ix_outbox_messages_times_sent". + /// + public string IxTimesSent { get; set; } = "ix_outbox_messages_times_sent"; + + /// + /// Gets the fully qualified table name including schema if not public. + /// + public string QualifiedTableName + => string.IsNullOrEmpty(Schema) || Schema == "public" ? $"\"{Table}\"" : $"\"{Schema}\".\"{Table}\""; +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/PostgresTableInfo.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/PostgresTableInfo.cs index da258a51719..ef4f290d852 100644 --- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/PostgresTableInfo.cs +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/PostgresTableInfo.cs @@ -15,123 +15,9 @@ public sealed class PostgresTableInfo /// Gets or sets the table and column metadata for the saga states table. /// public SagaStateTableInfo SagaState { get; set; } = new(); -} - -/// -/// Table and column information for the outbox messages table. -/// -public sealed class OutboxTableInfo -{ - /// - /// Gets or sets the database schema for the outbox table. Defaults to "public". - /// - public string Schema { get; set; } = "public"; - - /// - /// Gets or sets the table name for outbox messages. Defaults to "outbox_messages". - /// - public string Table { get; set; } = "outbox_messages"; - - /// - /// Gets or sets the column name for the outbox message identifier. Defaults to "id". - /// - public string Id { get; set; } = "id"; - - /// - /// Gets or sets the column name for the serialized message envelope. Defaults to "envelope". - /// - public string Envelope { get; set; } = "envelope"; - - /// - /// Gets or sets the column name tracking how many times the message has been dispatched. Defaults to "times_sent". - /// - public string TimesSent { get; set; } = "times_sent"; - - /// - /// Gets or sets the column name for the message creation timestamp. Defaults to "created_at". - /// - public string CreatedAt { get; set; } = "created_at"; - - /// - /// Gets or sets the name of the primary key index. Defaults to "ix_outbox_messages_primary_key". - /// - public string IxPrimaryKey { get; set; } = "ix_outbox_messages_primary_key"; - - /// - /// Gets or sets the name of the created-at index used for ordering outbox processing. Defaults to "ix_outbox_messages_created_at". - /// - public string IxCreatedAt { get; set; } = "ix_outbox_messages_created_at"; - - /// - /// Gets or sets the name of the times-sent index used for retry filtering. Defaults to "ix_outbox_messages_times_sent". - /// - public string IxTimesSent { get; set; } = "ix_outbox_messages_times_sent"; - - /// - /// Gets the fully qualified table name including schema if not public. - /// - public string QualifiedTableName - => string.IsNullOrEmpty(Schema) || Schema == "public" ? $"\"{Table}\"" : $"\"{Schema}\".\"{Table}\""; -} - -/// -/// Table and column information for the saga states table. -/// -public sealed class SagaStateTableInfo -{ - /// - /// Gets or sets the database schema for the saga states table. Defaults to "public". - /// - public string Schema { get; set; } = "public"; - - /// - /// Gets or sets the table name for saga states. Defaults to "saga_states". - /// - public string Table { get; set; } = "saga_states"; - - /// - /// Gets or sets the column name for the saga instance identifier. Defaults to "id". - /// - public string Id { get; set; } = "id"; - - /// - /// Gets or sets the column name for the optimistic concurrency version token. Defaults to "version". - /// - public string Version { get; set; } = "version"; - - /// - /// Gets or sets the column name for the logical saga type name. Defaults to "saga_name". - /// - public string SagaName { get; set; } = "saga_name"; - - /// - /// Gets or sets the column name for the serialized saga state JSON. Defaults to "state". - /// - public string State { get; set; } = "state"; - - /// - /// Gets or sets the column name for the creation timestamp. Defaults to "created_at". - /// - public string CreatedAt { get; set; } = "created_at"; - - /// - /// Gets or sets the column name for the last-updated timestamp. Defaults to "updated_at". - /// - public string UpdatedAt { get; set; } = "updated_at"; - - /// - /// Gets or sets the name of the composite primary key index on id and saga name. Defaults to "ix_saga_states_primary_key". - /// - public string IxPrimaryKey { get; set; } = "ix_saga_states_primary_key"; - - /// - /// Gets or sets the name of the created-at index. Defaults to "ix_saga_states_created_at". - /// - public string IxCreatedAt { get; set; } = "ix_saga_states_created_at"; /// - /// Gets the fully qualified table name including schema if not public. + /// Gets or sets the table and column metadata for the inbox messages table. /// - public string QualifiedTableName - => string.IsNullOrEmpty(Schema) || Schema == "public" ? $"\"{Table}\"" : $"\"{Schema}\".\"{Table}\""; + public InboxTableInfo Inbox { get; set; } = new(); } diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/SagaStateTableInfo.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/SagaStateTableInfo.cs new file mode 100644 index 00000000000..d6359a9c181 --- /dev/null +++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/SagaStateTableInfo.cs @@ -0,0 +1,63 @@ +namespace Mocha.EntityFrameworkCore.Postgres; + +/// +/// Table and column information for the saga states table. +/// +public sealed class SagaStateTableInfo +{ + /// + /// Gets or sets the database schema for the saga states table. Defaults to "public". + /// + public string Schema { get; set; } = "public"; + + /// + /// Gets or sets the table name for saga states. Defaults to "saga_states". + /// + public string Table { get; set; } = "saga_states"; + + /// + /// Gets or sets the column name for the saga instance identifier. Defaults to "id". + /// + public string Id { get; set; } = "id"; + + /// + /// Gets or sets the column name for the optimistic concurrency version token. Defaults to "version". + /// + public string Version { get; set; } = "version"; + + /// + /// Gets or sets the column name for the logical saga type name. Defaults to "saga_name". + /// + public string SagaName { get; set; } = "saga_name"; + + /// + /// Gets or sets the column name for the serialized saga state JSON. Defaults to "state". + /// + public string State { get; set; } = "state"; + + /// + /// Gets or sets the column name for the creation timestamp. Defaults to "created_at". + /// + public string CreatedAt { get; set; } = "created_at"; + + /// + /// Gets or sets the column name for the last-updated timestamp. Defaults to "updated_at". + /// + public string UpdatedAt { get; set; } = "updated_at"; + + /// + /// Gets or sets the name of the composite primary key index on id and saga name. Defaults to "ix_saga_states_primary_key". + /// + public string IxPrimaryKey { get; set; } = "ix_saga_states_primary_key"; + + /// + /// Gets or sets the name of the created-at index. Defaults to "ix_saga_states_created_at". + /// + public string IxCreatedAt { get; set; } = "ix_saga_states_created_at"; + + /// + /// Gets the fully qualified table name including schema if not public. + /// + public string QualifiedTableName + => string.IsNullOrEmpty(Schema) || Schema == "public" ? $"\"{Table}\"" : $"\"{Schema}\".\"{Table}\""; +} diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore/Outbox/OutboxEntityFrameworkCorePersistanceBuilderExtensions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore/Outbox/OutboxEntityFrameworkCorePersistanceBuilderExtensions.cs index 82e45e8927c..4c242aa27f8 100644 --- a/src/Mocha/src/Mocha.EntityFrameworkCore/Outbox/OutboxEntityFrameworkCorePersistanceBuilderExtensions.cs +++ b/src/Mocha/src/Mocha.EntityFrameworkCore/Outbox/OutboxEntityFrameworkCorePersistanceBuilderExtensions.cs @@ -16,21 +16,20 @@ public static class OutboxEntityFrameworkCorePersistanceBuilderExtensions /// /// The Entity Framework Core builder to configure. /// The same instance for chaining. - public static IEntityFrameworkCoreBuilder AddOutboxCore(this IEntityFrameworkCoreBuilder builder) + public static IEntityFrameworkCoreBuilder UseOutboxCore(this IEntityFrameworkCoreBuilder builder) { - builder.HostBuilder.AddOutboxCore(); + builder.HostBuilder.UseOutboxCore(); - builder.ConfigureEntityFrameworkServices( - (sp, services) => - { - var signal = sp.GetService(); + builder.ConfigureEntityFrameworkServices((sp, services) => + { + var signal = sp.GetService(); - if (signal is not null) - { - services.AddSingleton(new OutboxDbTransactionInterceptor(signal)); - services.AddSingleton(new OutboxSaveChangesInterceptor(signal)); - } - }); + if (signal is not null) + { + services.AddSingleton(new OutboxDbTransactionInterceptor(signal)); + services.AddSingleton(new OutboxSaveChangesInterceptor(signal)); + } + }); return builder; } } diff --git a/src/Mocha/src/Mocha.Inbox/Assembly.cs b/src/Mocha/src/Mocha.Inbox/Assembly.cs new file mode 100644 index 00000000000..18197782c8c --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/Assembly.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Mocha.Tests")] +[assembly: InternalsVisibleTo("Mocha.EntityFrameworkCore.Postgres")] +[assembly: InternalsVisibleTo("Mocha.EntityFrameworkCore.Postgres.Tests")] diff --git a/src/Mocha/src/Mocha.Inbox/ConsumeInboxMiddleware.cs b/src/Mocha/src/Mocha.Inbox/ConsumeInboxMiddleware.cs new file mode 100644 index 00000000000..de75fd029d1 --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/ConsumeInboxMiddleware.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Mocha.Features; + +namespace Mocha.Inbox; + +/// +/// Consumer middleware that checks the inbox for duplicate messages +/// and skips processing if the message has already been handled by the current consumer. +/// +/// +/// This middleware runs in the consumer pipeline, after the transaction middleware, +/// so that the inbox claim participates in the same database transaction as the +/// handler's side-effects. If the transaction rolls back, the inbox claim is also +/// rolled back, allowing the message to be redelivered and reprocessed. +/// +/// Deduplication is scoped to each consumer type, so different handlers can independently +/// process the same message. This supports fan-out scenarios where a single message is +/// routed to multiple consumers. +/// +public sealed class ConsumeInboxMiddleware(ILogger logger) +{ + /// + /// Atomically claims the incoming message via the inbox before processing. + /// If the message has already been claimed by the same consumer type, processing is skipped. + /// + /// + /// Uses the claim-before-process pattern to prevent duplicate message processing under + /// concurrent delivery. Instead of checking existence and then recording after processing + /// (which is vulnerable to TOCTOU race conditions), this middleware atomically inserts the + /// message ID and consumer type before processing. Only the consumer instance that + /// successfully claims the message will execute the handler. + /// + /// When a database transaction is active (e.g. from the transaction middleware), the inbox + /// claim INSERT participates in that transaction. This means the claim and the handler's + /// business data commit or rollback atomically. If the process crashes after the claim + /// but before the transaction commits, the claim is rolled back and the message can be + /// redelivered safely. + /// + /// Resilience behavior: + /// + /// If throws a transient error, the message is + /// passed through to the handler to guarantee at-least-once delivery. + /// + /// + /// The current consume context containing the message envelope and metadata. + /// The next middleware delegate in the consumer pipeline. + /// A value task that completes when the message has been processed or skipped. + public async ValueTask InvokeAsync(IConsumeContext context, ConsumerDelegate next) + { + var activity = Activity.Current; + var feature = context.Features.GetOrSet(); + + if (feature.SkipInbox) + { + activity?.SetTag("inbox.skipped", true); + + await next(context); + + return; + } + + var messageId = context.MessageId; + + if (messageId is null) + { + // No message ID, cannot deduplicate - pass through. + await next(context); + + return; + } + + var consumerType = GetConsumerType(context); + var inbox = context.Services.GetRequiredService(); + + if (context.Envelope is null) + { + // No envelope available to claim. Fall back to existence check to at + // least skip known duplicates, then process without recording. + try + { + if (await inbox.ExistsAsync(messageId, consumerType, context.CancellationToken)) + { + logger.MessageSkippedDueToInboxExists(messageId, consumerType); + + activity?.SetTag("inbox.claimed", false); + + return; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.InboxExistsCheckFailed(messageId, ex); + } + + activity?.SetTag("inbox.claimed", true); + + await next(context); + + return; + } + + // Claim-before-process: atomically insert the message ID and consumer type into the inbox. + // Only the consumer instance that successfully claims the message will process it. + try + { + if (!await inbox.TryClaimAsync(context.Envelope, consumerType, context.CancellationToken)) + { + // This consumer type already claimed this message, skip. + logger.MessageSkippedDueToInboxExists(messageId, consumerType); + + activity?.SetTag("inbox.claimed", false); + return; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // The claim failed (e.g. transient DB error). We pass through to the handler + // rather than dropping the message, preferring at-least-once over at-most-once delivery. + logger.InboxClaimFailed(messageId, ex); + } + + activity?.SetTag("inbox.claimed", true); + await next(context); + } + + /// + /// Gets the consumer type name from the current context by reading the + /// identity. + /// + /// The current consume context. + /// The full type name of the current consumer, or "unknown" if not available. + private static string GetConsumerType(IConsumeContext context) + { + var consumer = context.Features.Get()?.CurrentConsumer; + return consumer?.Identity.FullName ?? "unknown"; + } + + /// + /// Creates the middleware configuration that wires the inbox middleware into the consumer pipeline. + /// + /// A named "Inbox" for pipeline registration. + public static ConsumerMiddlewareConfiguration Create() + => new( + static (ctx, next) => + { + var logger = ctx.Services.GetRequiredService>(); + var middleware = new ConsumeInboxMiddleware(logger); + return ctx => middleware.InvokeAsync(ctx, next); + }, + "Inbox"); +} + +/// +/// Provides high-performance source-generated log methods for the inbox middleware. +/// +internal static partial class InboxMiddlewareLogs +{ + [LoggerMessage( + LogLevel.Warning, + "Inbox exists check failed for message {MessageId}. Message will be processed to avoid data loss.")] + public static partial void InboxExistsCheckFailed( + this ILogger logger, + string messageId, + Exception exception); + + [LoggerMessage( + LogLevel.Warning, + "Inbox claim failed for message {MessageId}. Message will be processed to avoid data loss.")] + public static partial void InboxClaimFailed( + this ILogger logger, + string messageId, + Exception exception); + + [LoggerMessage( + LogLevel.Information, + "Message {MessageId} skipped by inbox for consumer {ConsumerType}.")] + public static partial void MessageSkippedDueToInboxExists( + this ILogger logger, + string messageId, + string consumerType); +} diff --git a/src/Mocha/src/Mocha.Inbox/IMessageInbox.cs b/src/Mocha/src/Mocha.Inbox/IMessageInbox.cs new file mode 100644 index 00000000000..aeb453e638f --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/IMessageInbox.cs @@ -0,0 +1,72 @@ +using Mocha.Middlewares; + +namespace Mocha.Inbox; + +/// +/// Represents a message inbox for tracking processed messages +/// to ensure idempotent message consumption per consumer type. +/// +public interface IMessageInbox +{ + /// + /// Checks if a message has already been processed by a specific consumer type. + /// + /// The message identifier. + /// The consumer type name that identifies the handler. + /// The cancellation token. + /// + /// true if the message has been processed by the specified consumer; otherwise, false. + /// + ValueTask ExistsAsync( + string messageId, + string consumerType, + CancellationToken cancellationToken); + + /// + /// Atomically attempts to claim a message for processing by a specific consumer type + /// by inserting it into the inbox. If another instance of the same consumer type has + /// already claimed the message, returns false. + /// + /// + /// This method is the primary mechanism for preventing duplicate message processing under + /// concurrent delivery. It uses an atomic INSERT (with conflict detection) so that exactly + /// one caller wins the claim for a given and + /// combination. Callers should only process the message + /// when this method returns true. + /// + /// Different consumer types can independently claim and process the same message, enabling + /// fan-out scenarios where multiple handlers each process the same message exactly once. + /// + /// The message envelope to claim. + /// The consumer type name that identifies the handler. + /// The cancellation token. + /// + /// true if the message was successfully claimed (inserted); false if it was + /// already claimed by this consumer type. + /// + ValueTask TryClaimAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken); + + /// + /// Records a message as processed by a specific consumer type in the inbox. + /// + /// The message envelope to record. + /// The consumer type name that identifies the handler. + /// The cancellation token. + ValueTask RecordAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken); + + /// + /// Cleans up processed inbox messages older than the specified age. + /// + /// The maximum age of processed messages to retain. + /// The cancellation token. + /// The number of messages cleaned up. + ValueTask CleanupAsync( + TimeSpan maxAge, + CancellationToken cancellationToken); +} diff --git a/src/Mocha/src/Mocha.Inbox/InboxCleanupProcessor.cs b/src/Mocha/src/Mocha.Inbox/InboxCleanupProcessor.cs new file mode 100644 index 00000000000..053f0d91894 --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/InboxCleanupProcessor.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Mocha.Inbox; + +/// +/// Periodically removes old inbox entries that have exceeded the configured retention period. +/// Deletes in batches to avoid long-running locks on the inbox table. +/// +internal sealed class InboxCleanupProcessor( + IOptions options, + TimeProvider timeProvider, + IServiceProvider provider, + ILogger logger) +{ + private readonly TimeProvider _timeProvider = timeProvider; + + /// + /// Runs the cleanup loop, removing expired inbox entries at the configured interval. + /// + /// A token that signals when the processor should stop. + public async Task ProcessAsync(CancellationToken cancellationToken) + { + var inboxOptions = options.Value; + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(inboxOptions.CleanupInterval, cancellationToken); + await using var scope = provider.CreateAsyncScope(); + using var activity = OpenTelemetry.Source.StartActivity( + "Inbox Cleanup", + ActivityKind.Internal, + // No parent context since this runs in the background independently of message processing + // activities + parentContext: new ActivityContext()); + + var inbox = scope.ServiceProvider.GetRequiredService(); + + var startTime = Stopwatch.GetTimestamp(); + var totalDeleted = 0; + + int deleted; + do + { + deleted = await inbox.CleanupAsync(inboxOptions.RetentionPeriod, cancellationToken); + + totalDeleted += deleted; + } while (deleted > 0 && !cancellationToken.IsCancellationRequested); + + var elapsed = Stopwatch.GetElapsedTime(startTime); + + activity?.SetTag("inbox.cleanup.deleted_count", totalDeleted); + activity?.SetTag("inbox.cleanup.duration_ms", elapsed.TotalMilliseconds); + + if (totalDeleted > 0) + { + logger.InboxCleanupCompleted(totalDeleted, elapsed); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.InboxCleanupFailed(ex); + } + } + } +} + +internal static partial class InboxLogs +{ + [LoggerMessage(LogLevel.Information, "Inbox cleanup completed: deleted {Count} messages in {Duration}.")] + public static partial void InboxCleanupCompleted(this ILogger logger, int count, TimeSpan duration); + + [LoggerMessage(LogLevel.Error, "An unexpected error occurred during inbox cleanup.")] + public static partial void InboxCleanupFailed(this ILogger logger, Exception exception); + + [LoggerMessage(LogLevel.Information, "Inbox cleanup worker starting.")] + public static partial void InboxWorkerStarting(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Inbox cleanup worker stopping.")] + public static partial void InboxWorkerStopping(this ILogger logger); +} diff --git a/src/Mocha/src/Mocha.Inbox/InboxCoreServiceCollectionExtensions.cs b/src/Mocha/src/Mocha.Inbox/InboxCoreServiceCollectionExtensions.cs new file mode 100644 index 00000000000..dd0e32e1769 --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/InboxCoreServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +namespace Mocha.Inbox; + +/// +/// Provides extension methods to register inbox infrastructure on . +/// +public static class InboxCoreServiceCollectionExtensions +{ + /// + /// Registers the core inbox services and inserts the inbox consumer middleware into the message bus pipeline. + /// + /// + /// + /// The inbox middleware runs in the consumer pipeline so that the inbox claim + /// participates in the same database transaction as the handler's side-effects (when + /// UseTransaction() is configured). This makes the claim and the handler's + /// business data atomic: both commit or both rollback. + /// + /// + /// The middleware is appended to the consumer pipeline so that it runs after any + /// transaction middleware that may have been registered. + /// + /// + /// The message bus host builder to configure. + /// The same instance for chaining. + public static IMessageBusHostBuilder UseInboxCore(this IMessageBusHostBuilder builder) + { + builder.ConfigureMessageBus(x => x.AppendConsume(ConsumeInboxMiddleware.Create())); + + return builder; + } +} diff --git a/src/Mocha/src/Mocha.Inbox/InboxMiddlewareFeature.cs b/src/Mocha/src/Mocha.Inbox/InboxMiddlewareFeature.cs new file mode 100644 index 00000000000..b01e7c7e0d0 --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/InboxMiddlewareFeature.cs @@ -0,0 +1,36 @@ +using Mocha.Features; + +namespace Mocha.Inbox; + +/// +/// A pooled feature that controls whether the inbox middleware should be bypassed for a given receive. +/// +/// +/// Attach this feature to the receive context's feature collection and set +/// to true to process the message without checking the inbox for duplicates. +/// The feature is pooled and automatically reset between uses. +/// +public sealed class InboxMiddlewareFeature : IPooledFeature +{ + /// + /// Gets or sets a value indicating whether the inbox deduplication check should be skipped for the current receive. + /// + public bool SkipInbox { get; set; } + + /// + /// Initializes the feature from the pool, resetting to false. + /// + /// The initialization state provided by the feature pool (unused). + public void Initialize(object state) + { + SkipInbox = false; + } + + /// + /// Resets the feature state before returning it to the pool, clearing to false. + /// + public void Reset() + { + SkipInbox = false; + } +} diff --git a/src/Mocha/src/Mocha.Inbox/InboxOptions.cs b/src/Mocha/src/Mocha.Inbox/InboxOptions.cs new file mode 100644 index 00000000000..a755b4802c2 --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/InboxOptions.cs @@ -0,0 +1,18 @@ +namespace Mocha.Inbox; + +/// +/// Configuration options for the inbox. +/// +public sealed class InboxOptions +{ + /// + /// Gets or sets the retention period for processed messages. + /// Messages older than this will be cleaned up. + /// + public TimeSpan RetentionPeriod { get; set; } = TimeSpan.FromDays(7); + + /// + /// Gets or sets the cleanup interval for removing old processed messages. + /// + public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromHours(1); +} diff --git a/src/Mocha/src/Mocha.Inbox/MessageBusInboxWorker.cs b/src/Mocha/src/Mocha.Inbox/MessageBusInboxWorker.cs new file mode 100644 index 00000000000..e943b3009ba --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/MessageBusInboxWorker.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Mocha.Threading; + +namespace Mocha.Inbox; + +/// +/// A hosted service that manages the lifecycle of the inbox cleanup processor, +/// running the cleanup loop as a continuous background task. +/// +/// The inbox configuration options including retention period and cleanup interval. +/// The service provider used to resolve scoped services. +/// The time provider used for scheduling cleanup operations. +/// The logger for the cleanup processor. +/// The logger for the worker lifecycle. +internal sealed class MessageBusInboxWorker( + IOptions inboxOptions, + IServiceProvider provider, + TimeProvider timeProvider, + ILogger cleanupLogger, + ILogger logger) : IHostedService +{ + private ContinuousTask? _task; + + /// + /// Starts the inbox cleanup background task. + /// + /// A token that signals when startup should be aborted. + /// A completed task once the background loop has been initiated. + /// Thrown if the worker is already running. + public Task StartAsync(CancellationToken cancellationToken) + { + if (_task is not null) + { + throw new InvalidOperationException("The worker is already running."); + } + + logger.InboxWorkerStarting(); + + _task = new ContinuousTask(ProcessAsync); + + return Task.CompletedTask; + } + + /// + /// Stops the inbox cleanup background task and waits for it to complete gracefully. + /// + /// A token that signals when shutdown should be forced. + public async Task StopAsync(CancellationToken cancellationToken) + { + logger.InboxWorkerStopping(); + + if (_task is null) + { + return; + } + + await _task.DisposeAsync(); + _task = null; + } + + private async Task ProcessAsync(CancellationToken stoppingToken) + { + var processor = new InboxCleanupProcessor(inboxOptions, timeProvider, provider, cleanupLogger); + + await processor.ProcessAsync(stoppingToken); + } +} diff --git a/src/Mocha/src/Mocha.Inbox/Mocha.Inbox.csproj b/src/Mocha/src/Mocha.Inbox/Mocha.Inbox.csproj new file mode 100644 index 00000000000..d8545fa7ac3 --- /dev/null +++ b/src/Mocha/src/Mocha.Inbox/Mocha.Inbox.csproj @@ -0,0 +1,9 @@ + + + Mocha.Inbox + Mocha.Inbox + + + + + diff --git a/src/Mocha/src/Mocha.Outbox/OutboxCoreServiceCollectionExtensions.cs b/src/Mocha/src/Mocha.Outbox/OutboxCoreServiceCollectionExtensions.cs index ad86b8a1dc2..4ec2018c7ee 100644 --- a/src/Mocha/src/Mocha.Outbox/OutboxCoreServiceCollectionExtensions.cs +++ b/src/Mocha/src/Mocha.Outbox/OutboxCoreServiceCollectionExtensions.cs @@ -17,7 +17,7 @@ public static class OutboxCoreServiceCollectionExtensions /// /// The message bus host builder to configure. /// The same instance for chaining. - public static IMessageBusHostBuilder AddOutboxCore(this IMessageBusHostBuilder builder) + public static IMessageBusHostBuilder UseOutboxCore(this IMessageBusHostBuilder builder) { builder.Services.TryAddSingleton(); builder.ConfigureMessageBus(x => x.UseDispatch(DispatchOutboxMiddleware.Create())); diff --git a/src/Mocha/src/Mocha.Threading/ContinuousTask.cs b/src/Mocha/src/Mocha.Threading/ContinuousTask.cs index 9a005f4523a..67878b676c3 100644 --- a/src/Mocha/src/Mocha.Threading/ContinuousTask.cs +++ b/src/Mocha/src/Mocha.Threading/ContinuousTask.cs @@ -91,6 +91,18 @@ public async ValueTask DisposeAsync() #endif } + // Wait for the background loop to finish before disposing the CTS. + // This prevents ObjectDisposedException if RunContinuously is still + // accessing _completion.Token when we dispose it. + try + { + await _task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + _completion.Dispose(); _disposed = true; diff --git a/src/Mocha/src/Mocha/Assembly.cs b/src/Mocha/src/Mocha/Assembly.cs index 1c3957a4354..397985344e2 100644 --- a/src/Mocha/src/Mocha/Assembly.cs +++ b/src/Mocha/src/Mocha/Assembly.cs @@ -4,6 +4,7 @@ [assembly: InternalsVisibleTo("Mocha.Sagas")] [assembly: InternalsVisibleTo("Mocha.Sagas.TestHelpers")] [assembly: InternalsVisibleTo("Mocha.Sagas.Tests")] +[assembly: InternalsVisibleTo("Mocha.Inbox")] [assembly: InternalsVisibleTo("Mocha.Outbox")] [assembly: InternalsVisibleTo("Mocha.EntityFrameworkCore.Postgres")] [assembly: InternalsVisibleTo("Mocha.EntityFrameworkCore")] diff --git a/src/Mocha/src/Mocha/README.md b/src/Mocha/src/Mocha/README.md deleted file mode 100644 index 41853de7988..00000000000 --- a/src/Mocha/src/Mocha/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Pipeline - -## Receive - -Transport sets body, content type and cancellation token. - -- ContextInitialization - - Sets endpoint, transport, services, host -- MessageEnvelopeParsing - - - This is done by a transport middleware as it is different per transport. it leaves open the possibility to add other middleware before this one. - -- Instrumentation - - - Adds tracing information to the context. REQUIRES the headers to be already there - -- MessageTypeSelection - - - Selects the message type based on the content type - -- ReceiveConsumerSelection - - Selects the consumers based on the message type (recusrively) - -## Consume - -- Instrumentation - - Adds instrumentation for the handler - -## Dispatch - -- MessageTypeSelection - - - Selects the message type based on the content type - -- MessageFormatting - - Formats the message based on the message type diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Helpers/TestDbContext.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Helpers/TestDbContext.cs index dff01d8c044..960aa91014b 100644 --- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Helpers/TestDbContext.cs +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Helpers/TestDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Mocha.Inbox; using Mocha.Outbox; using Mocha.Sagas.EfCore; @@ -8,6 +9,7 @@ public sealed class TestDbContext(DbContextOptions options) : DbC { protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.AddPostgresInbox(); modelBuilder.AddPostgresOutbox(); modelBuilder.AddPostgresSagas(); } diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/InboxServiceRegistrationTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/InboxServiceRegistrationTests.cs new file mode 100644 index 00000000000..8c8203ed4a8 --- /dev/null +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/InboxServiceRegistrationTests.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Mocha.EntityFrameworkCore.Postgres.Tests.Helpers; +using Mocha.Inbox; +using Mocha.Outbox; +using Mocha.Transport.InMemory; + +namespace Mocha.EntityFrameworkCore.Postgres.Tests; + +public sealed class InboxServiceRegistrationTests +{ + private const string ConnectionString = "Host=localhost;Database=test"; + + [Fact] + public async Task UsePostgresInbox_Should_RegisterHostedService_When_Called() + { + // Arrange + await using var provider = BuildProvider(); + + // Act + var hostedServices = provider.GetServices(); + + // Assert + Assert.Contains(hostedServices, s => s is MessageBusInboxWorker); + } + + [Fact] + public async Task UsePostgresInbox_Should_RegisterScopedInbox_When_Called() + { + // Arrange + await using var provider = BuildProvider(); + + // Act + using var scope = provider.CreateScope(); + var inbox = scope.ServiceProvider.GetService(); + + // Assert + Assert.NotNull(inbox); + Assert.IsType(inbox); + } + + [Fact] + public async Task UsePostgresInbox_Should_ConfigureQueriesFromModel_When_DefaultTableNames() + { + // Arrange + await using var provider = BuildProvider(); + + // Act + var optionsMonitor = provider.GetRequiredService>(); + var contextName = typeof(TestDbContext).FullName!; + var options = optionsMonitor.Get(contextName); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(options.Queries.Exists)); + Assert.False(string.IsNullOrWhiteSpace(options.Queries.Insert)); + Assert.False(string.IsNullOrWhiteSpace(options.Queries.Cleanup)); + Assert.False(string.IsNullOrWhiteSpace(options.ConnectionString)); + } + + [Fact] + public async Task UsePostgresInbox_Should_UseDefaultInboxOptions_When_NoConfigure() + { + // Arrange + await using var provider = BuildProvider(); + + // Act + var inboxOptions = provider.GetRequiredService>().Value; + + // Assert + Assert.Equal(TimeSpan.FromDays(7), inboxOptions.RetentionPeriod); + Assert.Equal(TimeSpan.FromHours(1), inboxOptions.CleanupInterval); + } + + [Fact] + public async Task UsePostgresInbox_Should_UseCustomInboxOptions_When_ConfigureProvided() + { + // Arrange + await using var provider = BuildProvider(configure: opts => + { + opts.RetentionPeriod = TimeSpan.FromDays(14); + opts.CleanupInterval = TimeSpan.FromMinutes(30); + }); + + // Act + var inboxOptions = provider.GetRequiredService>().Value; + + // Assert + Assert.Equal(TimeSpan.FromDays(14), inboxOptions.RetentionPeriod); + Assert.Equal(TimeSpan.FromMinutes(30), inboxOptions.CleanupInterval); + } + + private static ServiceProvider BuildProvider(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(o => o.UseNpgsql(ConnectionString)); + + // Use a resilient signal to prevent ObjectDisposedException when + // EF Core shares the internal service provider (and interceptors) + // across test classes via ShouldUseSameServiceProvider. + services.AddSingleton(); + + var builder = services.AddMessageBus(); + builder.AddEntityFramework( + ef => ef.UsePostgresInbox(configure)); + builder.AddInMemory(); + + var provider = services.BuildServiceProvider(); + + // Build the runtime so that all singleton factories resolve + _ = provider.GetRequiredService(); + + return provider; + } +} diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj index b42f9bbf554..1db0c7b2bef 100644 --- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj @@ -5,6 +5,7 @@ + @@ -14,5 +15,6 @@ + diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/OutboxServiceRegistrationTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/OutboxServiceRegistrationTests.cs index 0421b24aaf1..0bcdafd1cbb 100644 --- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/OutboxServiceRegistrationTests.cs +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/OutboxServiceRegistrationTests.cs @@ -13,7 +13,7 @@ public sealed class OutboxServiceRegistrationTests private const string ConnectionString = "Host=localhost;Database=test"; [Fact] - public async Task AddPostgresOutbox_Should_RegisterHostedService_When_Called() + public async Task UsePostgresOutbox_Should_RegisterHostedService_When_Called() { // Arrange await using var provider = BuildProvider(); @@ -26,7 +26,7 @@ public async Task AddPostgresOutbox_Should_RegisterHostedService_When_Called() } [Fact] - public async Task AddPostgresOutbox_Should_RegisterScopedOutbox_When_Called() + public async Task UsePostgresOutbox_Should_RegisterScopedOutbox_When_Called() { // Arrange await using var provider = BuildProvider(); @@ -41,7 +41,7 @@ public async Task AddPostgresOutbox_Should_RegisterScopedOutbox_When_Called() } [Fact] - public async Task AddPostgresOutbox_Should_RegisterProcessor_When_Called() + public async Task UsePostgresOutbox_Should_RegisterProcessor_When_Called() { // Arrange await using var provider = BuildProvider(); @@ -54,7 +54,7 @@ public async Task AddPostgresOutbox_Should_RegisterProcessor_When_Called() } [Fact] - public async Task AddPostgresOutbox_Should_ConfigureQueriesFromModel_When_DefaultTableNames() + public async Task UsePostgresOutbox_Should_ConfigureQueriesFromModel_When_DefaultTableNames() { // Arrange await using var provider = BuildProvider(); @@ -84,7 +84,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(); var builder = services.AddMessageBus(); - builder.AddEntityFramework(ef => ef.AddPostgresOutbox()); + builder.AddEntityFramework(ef => ef.UsePostgresOutbox()); builder.AddInMemory(); var provider = services.BuildServiceProvider(); diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMessageInboxTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMessageInboxTests.cs new file mode 100644 index 00000000000..e1bbe63bfa2 --- /dev/null +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMessageInboxTests.cs @@ -0,0 +1,307 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Time.Testing; +using Mocha.EntityFrameworkCore.Postgres; +using Mocha.EntityFrameworkCore.Postgres.Tests.Helpers; +using Mocha.Inbox; +using Mocha.Middlewares; +using Npgsql; + +namespace Mocha.EntityFrameworkCore.Postgres.Tests; + +public sealed class PostgresMessageInboxTests : IClassFixture +{ + private const string TestConsumerType = "MyApp.OrderPlacedHandler"; + private const string AltConsumerType = "MyApp.NotificationHandler"; + + private readonly PostgresFixture _fixture; + + public PostgresMessageInboxTests(PostgresFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task ExistsAsync_Should_ReturnFalse_When_MessageNotRecorded() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + // Act + var exists = await inbox.ExistsAsync("non-existent-id", TestConsumerType, CancellationToken.None); + + // Assert + Assert.False(exists); + } + + [Fact] + public async Task RecordAsync_Should_InsertRow_When_Called() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + + // Act + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Assert + var connection = (NpgsqlConnection)context.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(CancellationToken.None); + } + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM \"inbox_messages\""; + var count = (long)(await cmd.ExecuteScalarAsync(CancellationToken.None))!; + + Assert.Equal(1, count); + } + + [Fact] + public async Task ExistsAsync_Should_ReturnTrue_When_MessageRecorded() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act + var exists = await inbox.ExistsAsync(envelope.MessageId!, TestConsumerType, CancellationToken.None); + + // Assert + Assert.True(exists); + } + + [Fact] + public async Task ExistsAsync_Should_ReturnFalse_When_DifferentConsumerType() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act — check with a different consumer type + var exists = await inbox.ExistsAsync(envelope.MessageId!, AltConsumerType, CancellationToken.None); + + // Assert + Assert.False(exists); + } + + [Fact] + public async Task RecordAsync_Should_NotThrow_When_DuplicateMessageInserted() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act & Assert — ON CONFLICT DO NOTHING should prevent errors + var ex = await Record.ExceptionAsync(() => + inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None).AsTask()); + + Assert.Null(ex); + } + + [Fact] + public async Task RecordAsync_Should_AllowSameMessageForDifferentConsumers_When_Called() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + + // Act — record same message for two different consumer types + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + await inbox.RecordAsync(envelope, AltConsumerType, CancellationToken.None); + + // Assert — both should exist + var existsFirst = await inbox.ExistsAsync(envelope.MessageId!, TestConsumerType, CancellationToken.None); + var existsSecond = await inbox.ExistsAsync(envelope.MessageId!, AltConsumerType, CancellationToken.None); + Assert.True(existsFirst); + Assert.True(existsSecond); + } + + [Fact] + public async Task TryClaimAsync_Should_ReturnTrue_When_MessageNotYetClaimed() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + + // Act + var claimed = await inbox.TryClaimAsync(envelope, TestConsumerType, CancellationToken.None); + + // Assert + Assert.True(claimed); + var exists = await inbox.ExistsAsync(envelope.MessageId!, TestConsumerType, CancellationToken.None); + Assert.True(exists); + } + + [Fact] + public async Task TryClaimAsync_Should_ReturnFalse_When_MessageAlreadyClaimed() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.TryClaimAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act — second claim attempt for the same message ID and consumer type + var claimed = await inbox.TryClaimAsync(envelope, TestConsumerType, CancellationToken.None); + + // Assert + Assert.False(claimed); + } + + [Fact] + public async Task TryClaimAsync_Should_ReturnTrue_When_DifferentConsumerType() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.TryClaimAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act — claim the same message with a different consumer type + var claimed = await inbox.TryClaimAsync(envelope, AltConsumerType, CancellationToken.None); + + // Assert — should succeed because each consumer type claims independently + Assert.True(claimed); + } + + [Fact] + public async Task TryClaimAsync_Should_ReturnFalse_When_MessageAlreadyRecorded() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act — try to claim a message that was already recorded via RecordAsync + var claimed = await inbox.TryClaimAsync(envelope, TestConsumerType, CancellationToken.None); + + // Assert + Assert.False(claimed); + } + + [Fact] + public async Task CleanupAsync_Should_DeleteOldMessages_When_Called() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Backdate the processed_at so the cleanup finds it + var connection = (NpgsqlConnection)context.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(CancellationToken.None); + } + + await using var backdateCmd = connection.CreateCommand(); + backdateCmd.CommandText = + "UPDATE \"inbox_messages\" SET \"processed_at\" = NOW() - INTERVAL '30 days'"; + await backdateCmd.ExecuteNonQueryAsync(CancellationToken.None); + + // Act + var deleted = await inbox.CleanupAsync(TimeSpan.FromDays(7), CancellationToken.None); + + // Assert + Assert.Equal(1, deleted); + } + + [Fact] + public async Task CleanupAsync_Should_NotDeleteRecentMessages_When_Called() + { + // Arrange + var (context, inbox) = await CreateInboxAsync(); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act — message was just inserted, so it should not be cleaned up + var deleted = await inbox.CleanupAsync(TimeSpan.FromDays(7), CancellationToken.None); + + // Assert + Assert.Equal(0, deleted); + } + + [Fact] + public async Task CleanupAsync_Should_DeleteMessages_When_TimeProviderAdvancedPastRetention() + { + // Arrange — start the fake clock at "now" and insert a message + var baseTime = DateTimeOffset.UtcNow; + var fakeTime = new FakeTimeProvider(baseTime); + var (context, inbox) = await CreateInboxAsync(fakeTime); + await using var _ = context; + + var envelope = CreateTestEnvelope(); + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + + // Act 1 — clock has not advanced, message should be retained + var deletedBefore = await inbox.CleanupAsync(TimeSpan.FromDays(7), CancellationToken.None); + Assert.Equal(0, deletedBefore); + + // Advance the fake clock past the retention period + fakeTime.Advance(TimeSpan.FromDays(8)); + + // Act 2 — now the message is older than the retention period + var deletedAfter = await inbox.CleanupAsync(TimeSpan.FromDays(7), CancellationToken.None); + + // Assert + Assert.Equal(1, deletedAfter); + } + + private async Task<(TestDbContext Context, PostgresMessageInbox Inbox)> CreateInboxAsync( + TimeProvider? timeProvider = null) + { + var connectionString = await _fixture.CreateDatabaseAsync(); + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + var context = new TestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + var connection = (NpgsqlConnection)context.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(CancellationToken.None); + } + + var queries = PostgresMessageInboxQueries.From(new InboxTableInfo()); + var inbox = new PostgresMessageInbox(context, connection, queries, timeProvider ?? TimeProvider.System); + return (context, inbox); + } + + private static MessageEnvelope CreateTestEnvelope() + { + return new MessageEnvelope + { + MessageId = Guid.NewGuid().ToString(), + MessageType = "urn:message:test-event", + DestinationAddress = "memory://test/queue", + SentAt = DateTimeOffset.UtcNow, + Body = "{\"value\":42}"u8.ToArray() + }; + } +} diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresOutboxIntegrationTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresOutboxIntegrationTests.cs index 88a68005d84..89990c06a62 100644 --- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresOutboxIntegrationTests.cs +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresOutboxIntegrationTests.cs @@ -106,7 +106,7 @@ public async Task Outbox_Should_ProcessPendingMessages_When_WorkerStartsAfterPer services.AddSingleton(); var builder = services.AddMessageBus(); - builder.AddEntityFramework(ef => ef.AddPostgresOutbox()); + builder.AddEntityFramework(ef => ef.UsePostgresOutbox()); builder.AddEventHandler(); builder.AddInMemory(); @@ -182,7 +182,7 @@ public async Task Outbox_Should_ResumeProcessing_When_WorkerRestartedAfterInterr services.AddSingleton(); var builder = services.AddMessageBus(); - builder.AddEntityFramework(ef => ef.AddPostgresOutbox()); + builder.AddEntityFramework(ef => ef.UsePostgresOutbox()); builder.AddEventHandler(); builder.AddInMemory(); @@ -331,14 +331,14 @@ private async Task CreateBusWithOutboxAsync(MessageRecorder rec services.AddLogging(); services.AddDbContext(o => o.UseNpgsql(connectionString)); - // Register the resilient signal BEFORE AddPostgresOutbox() so that + // Register the resilient signal BEFORE UsePostgresOutbox() so that // TryAddSingleton in AddOutboxCore() is a no-op. // This prevents ObjectDisposedException during teardown when the // outbox processor's own transaction commits fire the interceptor. services.AddSingleton(); var builder = services.AddMessageBus(); - builder.AddEntityFramework(ef => ef.AddPostgresOutbox()); + builder.AddEntityFramework(ef => ef.UsePostgresOutbox()); builder.AddEventHandler(); builder.AddInMemory(); diff --git a/src/Mocha/test/Mocha.Tests/Inbox/ConsumeInboxMiddlewareTests.cs b/src/Mocha/test/Mocha.Tests/Inbox/ConsumeInboxMiddlewareTests.cs new file mode 100644 index 00000000000..8e40a191207 --- /dev/null +++ b/src/Mocha/test/Mocha.Tests/Inbox/ConsumeInboxMiddlewareTests.cs @@ -0,0 +1,229 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Mocha.Features; +using Mocha.Inbox; +using Mocha.Middlewares; + +namespace Mocha.Tests.Inbox; + +public class ConsumeInboxMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_Should_CallNext_When_MessageIdIsNull() + { + // Arrange + var inbox = new InMemoryMessageInbox(); + var nextCalled = false; + + var middleware = new ConsumeInboxMiddleware(NullLogger.Instance); + var context = CreateConsumeContext(inbox, messageId: null); + + ConsumerDelegate next = _ => + { + nextCalled = true; + return ValueTask.CompletedTask; + }; + + // Act + await middleware.InvokeAsync(context, next); + + // Assert + Assert.True(nextCalled, "Next delegate should be called when MessageId is null"); + Assert.Empty(inbox.RecordedEnvelopes); + } + + [Fact] + public async Task InvokeAsync_Should_SkipNext_When_MessageAlreadyExists() + { + // Arrange + var inbox = new InMemoryMessageInbox(); + var messageId = Guid.NewGuid().ToString(); + var envelope = new MessageEnvelope { MessageId = messageId, MessageType = "urn:message:test" }; + + // Pre-record the message for the same consumer type + await inbox.RecordAsync(envelope, TestConsumerType, CancellationToken.None); + inbox.RecordedEnvelopes.Clear(); // Clear so we can detect if re-recorded + + var nextCalled = false; + var middleware = new ConsumeInboxMiddleware(NullLogger.Instance); + var context = CreateConsumeContext(inbox, messageId); + context.Envelope = envelope; + + ConsumerDelegate next = _ => + { + nextCalled = true; + return ValueTask.CompletedTask; + }; + + // Act + await middleware.InvokeAsync(context, next); + + // Assert + Assert.False(nextCalled, "Next delegate should NOT be called for duplicate messages"); + Assert.Empty(inbox.RecordedEnvelopes); + } + + [Fact] + public async Task InvokeAsync_Should_CallNextAndRecord_When_NewMessage() + { + // Arrange + var inbox = new InMemoryMessageInbox(); + var messageId = Guid.NewGuid().ToString(); + var envelope = new MessageEnvelope { MessageId = messageId, MessageType = "urn:message:test" }; + + var nextCalled = false; + var middleware = new ConsumeInboxMiddleware(NullLogger.Instance); + var context = CreateConsumeContext(inbox, messageId); + context.Envelope = envelope; + + ConsumerDelegate next = _ => + { + nextCalled = true; + return ValueTask.CompletedTask; + }; + + // Act + await middleware.InvokeAsync(context, next); + + // Assert + Assert.True(nextCalled, "Next delegate should be called for new messages"); + Assert.Single(inbox.RecordedEnvelopes); + Assert.Equal(messageId, inbox.RecordedEnvelopes.First().MessageId); + } + + [Fact] + public async Task InvokeAsync_Should_CallNextWithoutRecording_When_SkipInboxIsTrue() + { + // Arrange + var inbox = new InMemoryMessageInbox(); + var messageId = Guid.NewGuid().ToString(); + var envelope = new MessageEnvelope { MessageId = messageId, MessageType = "urn:message:test" }; + + var nextCalled = false; + var middleware = new ConsumeInboxMiddleware(NullLogger.Instance); + var context = CreateConsumeContext(inbox, messageId); + context.Envelope = envelope; + + // Set SkipInbox before middleware runs + var feature = context.Features.GetOrSet(); + feature.SkipInbox = true; + + ConsumerDelegate next = _ => + { + nextCalled = true; + return ValueTask.CompletedTask; + }; + + // Act + await middleware.InvokeAsync(context, next); + + // Assert + Assert.True(nextCalled, "Next delegate should be called when SkipInbox is true"); + Assert.Empty(inbox.RecordedEnvelopes); + } + + [Fact] + public async Task InvokeAsync_Should_NotRecord_When_EnvelopeIsNullAfterNext() + { + // Arrange + var inbox = new InMemoryMessageInbox(); + var messageId = Guid.NewGuid().ToString(); + + var middleware = new ConsumeInboxMiddleware(NullLogger.Instance); + var context = CreateConsumeContext(inbox, messageId); + // Envelope is intentionally null + + ConsumerDelegate next = _ => ValueTask.CompletedTask; + + // Act + await middleware.InvokeAsync(context, next); + + // Assert — next was called but no recording because Envelope is null + Assert.Empty(inbox.RecordedEnvelopes); + } + + [Fact] + public async Task InvokeAsync_Should_ProcessOnlyOnce_When_ConcurrentConsumersClaimSameMessage() + { + // Arrange + var inbox = new InMemoryMessageInbox(); + var messageId = Guid.NewGuid().ToString(); + var envelope = new MessageEnvelope { MessageId = messageId, MessageType = "urn:message:test" }; + + var processedCount = 0; + const int concurrency = 50; + using var barrier = new Barrier(concurrency); + + ConsumerDelegate next = _ => + { + Interlocked.Increment(ref processedCount); + return ValueTask.CompletedTask; + }; + + // Act — launch N concurrent consumers all trying to process the same MessageId + var tasks = Enumerable.Range(0, concurrency).Select(_ => Task.Run(async () => + { + var middleware = new ConsumeInboxMiddleware(NullLogger.Instance); + var context = CreateConsumeContext(inbox, messageId); + context.Envelope = envelope; + + // Synchronize all tasks to maximize contention + barrier.SignalAndWait(); + await middleware.InvokeAsync(context, next); + })); + + await Task.WhenAll(tasks); + + // Assert — exactly one consumer should have processed the message + Assert.Equal(1, processedCount); + Assert.Single(inbox.RecordedEnvelopes); + Assert.Equal(messageId, inbox.RecordedEnvelopes.First().MessageId); + } + + /// + /// The consumer type name used in tests to simulate a consumer identity. + /// Matches the full type name of the nested class. + /// + private static readonly string TestConsumerType = typeof(TestConsumer).FullName!; + + /// + /// Creates a (which implements both + /// and ) + /// for use in consumer middleware tests. + /// + private static ReceiveContext CreateConsumeContext( + IMessageInbox inbox, + string? messageId) + { + var services = new ServiceCollection(); + services.AddSingleton(inbox); + var provider = services.BuildServiceProvider(); + + var context = new ReceiveContext(); + context.MessageId = messageId; + context.Services = provider; + + // Set up the ReceiveConsumerFeature with a mock consumer identity + // so the inbox middleware can determine the consumer type. + var consumerFeature = context.Features.GetOrSet(); + consumerFeature.CurrentConsumer = new TestConsumer(); + + return context; + } + + /// + /// A test consumer whose type provides the consumer type name + /// used in inbox deduplication. + /// + private sealed class TestConsumer : Consumer + { + public TestConsumer() + { + SetIdentity(typeof(TestConsumer)); + } + + protected override ValueTask ConsumeAsync(IConsumeContext context) + => ValueTask.CompletedTask; + } +} diff --git a/src/Mocha/test/Mocha.Tests/Inbox/InMemoryMessageInbox.cs b/src/Mocha/test/Mocha.Tests/Inbox/InMemoryMessageInbox.cs new file mode 100644 index 00000000000..8676be9debb --- /dev/null +++ b/src/Mocha/test/Mocha.Tests/Inbox/InMemoryMessageInbox.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; +using Mocha.Inbox; +using Mocha.Middlewares; + +namespace Mocha.Tests.Inbox; + +internal sealed class InMemoryMessageInbox : IMessageInbox +{ + private readonly ConcurrentDictionary<(string MessageId, string ConsumerType), MessageEnvelope> _processed = new(); + + public ConcurrentBag RecordedEnvelopes { get; } = []; + + public ValueTask ExistsAsync( + string messageId, + string consumerType, + CancellationToken cancellationToken) + { + return ValueTask.FromResult(_processed.ContainsKey((messageId, consumerType))); + } + + public ValueTask TryClaimAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken) + { + if (envelope.MessageId is null) + { + return ValueTask.FromResult(false); + } + + var claimed = _processed.TryAdd((envelope.MessageId, consumerType), envelope); + if (claimed) + { + RecordedEnvelopes.Add(envelope); + } + + return ValueTask.FromResult(claimed); + } + + public ValueTask RecordAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken) + { + if (envelope.MessageId is not null) + { + _processed.TryAdd((envelope.MessageId, consumerType), envelope); + } + + RecordedEnvelopes.Add(envelope); + return ValueTask.CompletedTask; + } + + public ValueTask CleanupAsync( + TimeSpan maxAge, + CancellationToken cancellationToken) + { + return ValueTask.FromResult(0); + } +} diff --git a/src/Mocha/test/Mocha.Tests/Inbox/InboxIntegrationTests.cs b/src/Mocha/test/Mocha.Tests/Inbox/InboxIntegrationTests.cs new file mode 100644 index 00000000000..11a95578504 --- /dev/null +++ b/src/Mocha/test/Mocha.Tests/Inbox/InboxIntegrationTests.cs @@ -0,0 +1,192 @@ +using Microsoft.Extensions.DependencyInjection; +using Mocha.Features; +using Mocha.Inbox; +using Mocha.Transport.InMemory; + +namespace Mocha.Tests.Inbox; + +public class InboxIntegrationTests +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task Inbox_Should_RecordMessage_When_EventReceived() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act + await bus.PublishAsync(new InboxTestEvent { Payload = "record-me" }, CancellationToken.None); + + // assert — handler received the message and inbox recorded it + Assert.True(await recorder.WaitAsync(s_timeout), "Handler did not receive the event within timeout"); + await WaitUntilAsync(() => inbox.RecordedEnvelopes.Count >= 1, s_timeout); + Assert.Single(inbox.RecordedEnvelopes); + } + + [Fact] + // THis test is wrong? + public async Task Inbox_Should_DeduplicateMessage_When_SameEventPublishedTwice() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act — publish two distinct messages (each gets its own MessageId) + await bus.PublishAsync(new InboxTestEvent { Payload = "first" }, CancellationToken.None); + await bus.PublishAsync(new InboxTestEvent { Payload = "second" }, CancellationToken.None); + + // assert — both messages are received and recorded (they have different IDs) + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 2), + "Handler did not receive both events within timeout"); + + await WaitUntilAsync(() => inbox.RecordedEnvelopes.Count >= 2, s_timeout); + Assert.Equal(2, inbox.RecordedEnvelopes.Count); + } + + [Fact] + public async Task Inbox_Should_RecordMultipleMessages_When_MultipleEventsReceived() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act + await bus.PublishAsync(new InboxTestEvent { Payload = "first" }, CancellationToken.None); + await bus.PublishAsync(new InboxTestEvent { Payload = "second" }, CancellationToken.None); + await bus.PublishAsync(new InboxTestEvent { Payload = "third" }, CancellationToken.None); + + // assert — all three captured + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 3), + "Handler did not receive all 3 events within timeout"); + + await WaitUntilAsync(() => inbox.RecordedEnvelopes.Count >= 3, s_timeout); + Assert.Equal(3, inbox.RecordedEnvelopes.Count); + } + + [Fact] + public async Task Inbox_Should_SkipRecording_When_SkipInboxFeatureSet() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + + // Add a consumer middleware before inbox that sets SkipInbox + b.ConfigureMessageBus(h => + h.PrependConsume( + "Inbox", + new ConsumerMiddlewareConfiguration( + static (_, next) => + ctx => + { + var feature = ctx.Features.GetOrSet(); + feature.SkipInbox = true; + return next(ctx); + }, + "SkipInboxCheck")) + ); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act + await bus.PublishAsync(new InboxTestEvent { Payload = "skip-inbox" }, CancellationToken.None); + + // assert — handler received the message but inbox did NOT record it + Assert.True(await recorder.WaitAsync(s_timeout), "Handler did not receive the event within timeout"); + + // Give a short delay to ensure no async recording happens + await Task.Delay(200); + Assert.Empty(inbox.RecordedEnvelopes); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (!condition()) + { + await Task.Delay(50, cts.Token); + } + } + + private static async Task CreateBusWithInboxAsync( + InMemoryMessageInbox inbox, + Action configure) + { + var services = new ServiceCollection(); + services.AddSingleton(inbox); + + var builder = services.AddMessageBus(); + builder.UseInboxCore(); + + configure(builder); + builder.AddInMemory(); + + var provider = services.BuildServiceProvider(); + var runtime = (MessagingRuntime)provider.GetRequiredService(); + await runtime.StartAsync(CancellationToken.None); + return provider; + } + + // ══════════════════════════════════════════════════════════════════════ + // Test types + // ══════════════════════════════════════════════════════════════════════ + + public sealed class InboxTestEvent + { + public required string Payload { get; init; } + } + + public sealed class InboxTestEventHandler(MessageRecorder recorder) : IEventHandler + { + public ValueTask HandleAsync(InboxTestEvent message, CancellationToken cancellationToken) + { + recorder.Record(message); + return default; + } + } +} diff --git a/src/Mocha/test/Mocha.Tests/Infrastructure/DeferredResponseManagerTests.cs b/src/Mocha/test/Mocha.Tests/Infrastructure/DeferredResponseManagerTests.cs index f792a689169..c2c344f7084 100644 --- a/src/Mocha/test/Mocha.Tests/Infrastructure/DeferredResponseManagerTests.cs +++ b/src/Mocha/test/Mocha.Tests/Infrastructure/DeferredResponseManagerTests.cs @@ -140,9 +140,8 @@ public async Task GetPromise_Should_WaitForPromiseToComplete_When_PromiseAdded() var correlationId = Guid.NewGuid().ToString(); manager.AddPromise(correlationId, TimeSpan.FromSeconds(30)); - // act - start getting promise, complete it from another task - var getTask = Task.Run(() => manager.GetPromise(correlationId)); - await Task.Delay(50, default); // ensure GetPromise is waiting + // act - GetPromise synchronously looks up the TCS then returns a task awaiting it + var getTask = manager.GetPromise(correlationId); manager.CompletePromise(correlationId, "delayed-result"); // assert diff --git a/src/Mocha/test/Mocha.Tests/Mocha.Tests.csproj b/src/Mocha/test/Mocha.Tests/Mocha.Tests.csproj index 48a60dc3585..2bfcdd5a38f 100644 --- a/src/Mocha/test/Mocha.Tests/Mocha.Tests.csproj +++ b/src/Mocha/test/Mocha.Tests/Mocha.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Mocha/test/Mocha.Tests/Outbox/OutboxIntegrationTests.cs b/src/Mocha/test/Mocha.Tests/Outbox/OutboxIntegrationTests.cs index fd48dc55d48..cf0edef2057 100644 --- a/src/Mocha/test/Mocha.Tests/Outbox/OutboxIntegrationTests.cs +++ b/src/Mocha/test/Mocha.Tests/Outbox/OutboxIntegrationTests.cs @@ -147,7 +147,7 @@ private static async Task CreateBusWithOutboxAsync( services.AddSingleton(outbox); var builder = services.AddMessageBus(); - builder.AddOutboxCore(); + builder.UseOutboxCore(); // Add a middleware before outbox that checks for the skip header builder.ConfigureMessageBus(h => diff --git a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Behaviors/InboxTests.cs b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Behaviors/InboxTests.cs new file mode 100644 index 00000000000..d45e2cfcd38 --- /dev/null +++ b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Behaviors/InboxTests.cs @@ -0,0 +1,618 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Mocha.Features; +using Mocha.Inbox; +using Mocha.Middlewares; +using Mocha.Transport.InMemory.Tests.Helpers; + +namespace Mocha.Transport.InMemory.Tests.Behaviors; + +public class InboxTests +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task Inbox_Should_DeduplicateMessage_When_SameMessageIdPublishedTwice() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + var messageId = Guid.NewGuid().ToString(); + + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + + // Force every dispatched message to use the same MessageId + b.ConfigureMessageBus(h => + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = messageId; + return next(ctx); + }, + "ForceMessageId"))); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act — publish the first message; handler should process it + await bus.PublishAsync(new InboxEvent { Payload = "first" }, CancellationToken.None); + Assert.True(await recorder.WaitAsync(s_timeout), "Handler did not receive the first event"); + await WaitUntilAsync(() => inbox.RecordedEnvelopes.Count >= 1, s_timeout); + + // act — publish a second message with the same MessageId; handler should NOT process it + await bus.PublishAsync(new InboxEvent { Payload = "second" }, CancellationToken.None); + + // assert — only the first message was handled + Assert.False( + await recorder.WaitAsync(TimeSpan.FromMilliseconds(500), expectedCount: 2), + "Handler should not have received the duplicate message"); + Assert.Single(recorder.Messages); + + var handled = Assert.IsType(recorder.Messages.First()); + Assert.Equal("first", handled.Payload); + } + + [Fact] + public async Task Inbox_Should_ProcessBothMessages_When_DifferentMessageIds() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act — publish two distinct messages (each gets a unique auto-generated MessageId) + await bus.PublishAsync(new InboxEvent { Payload = "alpha" }, CancellationToken.None); + await bus.PublishAsync(new InboxEvent { Payload = "beta" }, CancellationToken.None); + + // assert — both messages are handled + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 2), + "Handler did not receive both events within timeout"); + + Assert.Equal(2, recorder.Messages.Count); + + var payloads = recorder.Messages + .Cast() + .Select(e => e.Payload) + .OrderBy(p => p) + .ToList(); + + Assert.Equal(["alpha", "beta"], payloads); + + // Both should be recorded in the inbox + await WaitUntilAsync(() => inbox.RecordedEnvelopes.Count >= 2, s_timeout); + Assert.Equal(2, inbox.RecordedEnvelopes.Count); + } + + [Fact] + public async Task Inbox_Should_ProcessMessage_When_SkipInboxIsSet() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + var messageId = Guid.NewGuid().ToString(); + + // Pre-seed the inbox so the MessageId is already "processed" + inbox.Seed(messageId); + + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + + // Force the dispatched message to use the pre-seeded MessageId + b.ConfigureMessageBus(h => + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = messageId; + return next(ctx); + }, + "ForceMessageId"))); + + // Add a consumer middleware before inbox that sets SkipInbox + b.ConfigureMessageBus(h => + h.PrependConsume( + "Inbox", + new ConsumerMiddlewareConfiguration( + static (_, next) => + ctx => + { + var feature = ctx.Features.GetOrSet(); + feature.SkipInbox = true; + return next(ctx); + }, + "SkipInboxCheck"))); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act — even though the MessageId is already in the inbox, SkipInbox bypasses the check + await bus.PublishAsync(new InboxEvent { Payload = "skip-inbox" }, CancellationToken.None); + + // assert — handler received the message despite the duplicate MessageId + Assert.True(await recorder.WaitAsync(s_timeout), "Handler did not receive the event within timeout"); + + var handled = Assert.IsType(Assert.Single(recorder.Messages)); + Assert.Equal("skip-inbox", handled.Payload); + } + + [Fact] + public async Task Inbox_Should_ProcessMessage_When_MessageIdIsNull() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + + await using var provider = await CreateBusWithInboxAsync( + inbox, + b => + { + b.Services.AddSingleton(recorder); + b.AddEventHandler(); + + // Null out the MessageId on the dispatch side + b.ConfigureMessageBus(h => + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = null; + return next(ctx); + }, + "NullifyMessageId"))); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act — publish two messages with null MessageIds + await bus.PublishAsync(new InboxEvent { Payload = "no-id-1" }, CancellationToken.None); + await bus.PublishAsync(new InboxEvent { Payload = "no-id-2" }, CancellationToken.None); + + // assert — both are processed (null MessageId means no dedup) + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 2), + "Handler did not receive both events within timeout"); + + Assert.Equal(2, recorder.Messages.Count); + } + + [Fact] + public async Task Inbox_Should_SkipSucceededHandler_And_RetryFailedHandler_When_MultipleHandlersRegistered() + { + // arrange + var inbox = new TransactionalInMemoryMessageInbox(); + var succeedingCounter = new InvocationCounter(); + var failingCounter = new InvocationCounter(); + var messageId = Guid.NewGuid().ToString(); + + await using var provider = await CreateBusWithTransactionalInboxAsync( + inbox, + b => + { + b.Services.AddKeyedSingleton("succeeding", succeedingCounter); + b.Services.AddKeyedSingleton("failing", failingCounter); + b.Services.AddSingleton(); + b.AddEventHandler(); + b.AddEventHandler(); + + // Force every dispatched message to use the same MessageId + b.ConfigureMessageBus(h => + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = messageId; + return next(ctx); + }, + "ForceMessageId"))); + }); + + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + + // act — first publish: FailingHandler will throw on first attempt. + // The consumer loop in DefaultPipeline iterates handlers sequentially; + // when FailingHandler throws, the exception propagates and any handler + // not yet reached in that iteration does not run for this delivery. + // The transactional rollback middleware removes the failed handler's claim. + await bus.PublishAsync(new MultiHandlerEvent { Payload = "attempt-1" }, CancellationToken.None); + + // Wait for the failing handler to have been invoked (it throws on first attempt) + Assert.True( + await failingCounter.WaitForCountAsync(1, s_timeout), + "FailingHandler was not invoked on the first attempt"); + + // Give the first delivery time to fully settle (fault middleware, etc.) + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + // Capture counters after first delivery. + // Depending on consumer iteration order: + // - If SucceedingHandler ran first: succeedingCount=1, failingCount=1 + // - If FailingHandler ran first: succeedingCount=0, failingCount=1 + var succeedingCountAfterFirstDelivery = succeedingCounter.Count; + var failingCountAfterFirstDelivery = failingCounter.Count; + + Assert.Equal(1, failingCountAfterFirstDelivery); + Assert.True( + succeedingCountAfterFirstDelivery is 0 or 1, + $"SucceedingHandler should have been invoked 0 or 1 times, was {succeedingCountAfterFirstDelivery}"); + + // act — re-publish with same MessageId to simulate transport redelivery. + // On this delivery: + // - SucceedingHandler: if it ran before, inbox dedup skips it; if it didn't run, it processes now + // - FailingHandler: claim was rolled back, so inbox allows retry; second attempt succeeds + await bus.PublishAsync(new MultiHandlerEvent { Payload = "attempt-2" }, CancellationToken.None); + + // Wait for the failing handler to succeed on retry (second invocation) + Assert.True( + await failingCounter.WaitForCountAsync(2, s_timeout), + "FailingHandler was not retried on the second delivery"); + + // If SucceedingHandler didn't run on first delivery, it should run now + if (succeedingCountAfterFirstDelivery == 0) + { + Assert.True( + await succeedingCounter.WaitForCountAsync(1, s_timeout), + "SucceedingHandler should have processed on the second delivery"); + } + + // Give a short window to ensure no extra invocation arrives + await Task.Delay(TimeSpan.FromMilliseconds(300)); + + // assert — across both deliveries: + // SucceedingHandler invoked exactly once (either on first or second delivery, but not both) + Assert.Equal(1, succeedingCounter.Count); + + // FailingHandler invoked exactly twice (failed first attempt + succeeded retry) + Assert.Equal(2, failingCounter.Count); + + // Both handlers should now have committed claims in the inbox + Assert.True( + await inbox.ExistsAsync(messageId, typeof(SucceedingHandler).FullName!, CancellationToken.None), + "SucceedingHandler should have a committed inbox claim"); + Assert.True( + await inbox.ExistsAsync(messageId, typeof(FailingHandler).FullName!, CancellationToken.None), + "FailingHandler should have a committed inbox claim after successful retry"); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (!condition()) + { + await Task.Delay(50, cts.Token); + } + } + + private static async Task CreateBusWithInboxAsync( + InMemoryMessageInbox inbox, + Action configure) + { + var services = new ServiceCollection(); + services.AddSingleton(inbox); + + var builder = services.AddMessageBus(); + builder.UseInboxCore(); + + configure(builder); + builder.AddInMemory(); + + var provider = services.BuildServiceProvider(); + var runtime = (MessagingRuntime)provider.GetRequiredService(); + await runtime.StartAsync(CancellationToken.None); + return provider; + } + + private static async Task CreateBusWithTransactionalInboxAsync( + TransactionalInMemoryMessageInbox inbox, + Action configure) + { + var services = new ServiceCollection(); + services.AddSingleton(inbox); + + var builder = services.AddMessageBus(); + builder.UseInboxCore(); + + // Add a consumer middleware BEFORE the inbox that rolls back the claim on failure. + // This simulates the transactional behavior where a DB transaction rollback would + // remove the inbox claim when the handler throws. + // The inbox is captured directly in the closure (not resolved from DI) because the + // consumer middleware factory runs against an internal service provider that does not + // contain application-level singletons. + builder.ConfigureMessageBus(h => + h.PrependConsume( + "Inbox", + new ConsumerMiddlewareConfiguration( + (_, next) => + async ctx => + { + try + { + await next(ctx); + } + catch + { + // On failure, roll back the inbox claim so the message can be + // reprocessed by this consumer on redelivery. + var msgId = ctx.MessageId; + var consumer = ctx.Features.Get()?.CurrentConsumer; + var consumerType = consumer?.Identity?.FullName ?? "unknown"; + if (msgId is not null) + { + inbox.RemoveClaim(msgId, consumerType); + } + + throw; + } + }, + "InboxTransactionRollback"))); + + configure(builder); + builder.AddInMemory(); + + var provider = services.BuildServiceProvider(); + var runtime = (MessagingRuntime)provider.GetRequiredService(); + await runtime.StartAsync(CancellationToken.None); + return provider; + } + + // ══════════════════════════════════════════════════════════════════════ + // Test types + // ══════════════════════════════════════════════════════════════════════ + + public sealed class InboxEvent + { + public required string Payload { get; init; } + } + + public sealed class InboxEventHandler(MessageRecorder recorder) : IEventHandler + { + public ValueTask HandleAsync(InboxEvent message, CancellationToken cancellationToken) + { + recorder.Record(message); + return default; + } + } + + /// + /// In-memory inbox that tracks processed message IDs and recorded envelopes for test assertions. + /// + internal sealed class InMemoryMessageInbox : IMessageInbox + { + private readonly ConcurrentDictionary<(string MessageId, string ConsumerType), MessageEnvelope> _processed = new(); + + public ConcurrentBag RecordedEnvelopes { get; } = []; + + /// + /// Pre-seeds a message ID into the inbox so it appears as already processed for all consumer types. + /// Uses a well-known consumer type for seeding. + /// + public void Seed(string messageId) + { + _processed.TryAdd((messageId, "*"), new MessageEnvelope { MessageId = messageId }); + } + + public ValueTask ExistsAsync(string messageId, string consumerType, CancellationToken cancellationToken) + { + return ValueTask.FromResult( + _processed.ContainsKey((messageId, consumerType)) + || _processed.ContainsKey((messageId, "*"))); + } + + public ValueTask TryClaimAsync(MessageEnvelope envelope, string consumerType, CancellationToken cancellationToken) + { + if (envelope.MessageId is null) + { + return ValueTask.FromResult(false); + } + + // Check if seeded with wildcard + if (_processed.ContainsKey((envelope.MessageId, "*"))) + { + return ValueTask.FromResult(false); + } + + var claimed = _processed.TryAdd((envelope.MessageId, consumerType), envelope); + if (claimed) + { + RecordedEnvelopes.Add(envelope); + } + + return ValueTask.FromResult(claimed); + } + + public ValueTask RecordAsync(MessageEnvelope envelope, string consumerType, CancellationToken cancellationToken) + { + if (envelope.MessageId is not null) + { + _processed.TryAdd((envelope.MessageId, consumerType), envelope); + } + + RecordedEnvelopes.Add(envelope); + return ValueTask.CompletedTask; + } + + public ValueTask CleanupAsync(TimeSpan maxAge, CancellationToken cancellationToken) + { + return ValueTask.FromResult(0); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // Multi-handler inbox test types + // ══════════════════════════════════════════════════════════════════════ + + public sealed class MultiHandlerEvent + { + public required string Payload { get; init; } + } + + /// + /// Thread-safe invocation counter with async wait support. + /// Registered as a singleton so it persists across scoped handler instances. + /// + public sealed class InvocationCounter + { + private int _count; + private readonly SemaphoreSlim _semaphore = new(0); + + public int Count => Volatile.Read(ref _count); + + public void Increment() + { + Interlocked.Increment(ref _count); + _semaphore.Release(); + } + + public async Task WaitForCountAsync(int expectedCount, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + try + { + while (Volatile.Read(ref _count) < expectedCount) + { + if (!await _semaphore.WaitAsync(timeout)) + { + return Volatile.Read(ref _count) >= expectedCount; + } + } + + return true; + } + catch (OperationCanceledException) + { + return Volatile.Read(ref _count) >= expectedCount; + } + } + } + + /// + /// Tracks how many times the failing handler has been attempted. + /// Registered as a singleton so it persists across scoped handler instances. + /// + public sealed class FailingHandlerAttemptTracker + { + private int _attempts; + + public int IncrementAndGet() => Interlocked.Increment(ref _attempts); + } + + /// + /// Handler that always succeeds and increments a counter. + /// + public sealed class SucceedingHandler( + [FromKeyedServices("succeeding")] InvocationCounter counter) : IEventHandler + { + public ValueTask HandleAsync(MultiHandlerEvent message, CancellationToken cancellationToken) + { + counter.Increment(); + return default; + } + } + + /// + /// Handler that throws on the first invocation and succeeds on subsequent invocations. + /// The attempt counter is tracked via a singleton + /// because handlers are scoped and a new instance is created for each message delivery. + /// + public sealed class FailingHandler( + [FromKeyedServices("failing")] InvocationCounter counter, + FailingHandlerAttemptTracker attemptTracker) : IEventHandler + { + public ValueTask HandleAsync(MultiHandlerEvent message, CancellationToken cancellationToken) + { + counter.Increment(); + + if (attemptTracker.IncrementAndGet() == 1) + { + throw new InvalidOperationException("Deliberate failure on first attempt"); + } + + return default; + } + } + + /// + /// In-memory inbox that supports claim removal, simulating transaction rollback behavior. + /// When a handler fails within a transaction, the claim INSERT is rolled back, allowing + /// the message to be reprocessed by that handler on redelivery. + /// + internal sealed class TransactionalInMemoryMessageInbox : IMessageInbox + { + private readonly ConcurrentDictionary<(string MessageId, string ConsumerType), MessageEnvelope> _processed = new(); + + public ConcurrentBag RecordedEnvelopes { get; } = []; + + /// + /// Removes a previously claimed entry, simulating a transaction rollback. + /// + public void RemoveClaim(string messageId, string consumerType) + { + _processed.TryRemove((messageId, consumerType), out _); + } + + public ValueTask ExistsAsync(string messageId, string consumerType, CancellationToken cancellationToken) + { + return ValueTask.FromResult(_processed.ContainsKey((messageId, consumerType))); + } + + public ValueTask TryClaimAsync(MessageEnvelope envelope, string consumerType, CancellationToken cancellationToken) + { + if (envelope.MessageId is null) + { + return ValueTask.FromResult(false); + } + + var claimed = _processed.TryAdd((envelope.MessageId, consumerType), envelope); + if (claimed) + { + RecordedEnvelopes.Add(envelope); + } + + return ValueTask.FromResult(claimed); + } + + public ValueTask RecordAsync(MessageEnvelope envelope, string consumerType, CancellationToken cancellationToken) + { + if (envelope.MessageId is not null) + { + _processed.TryAdd((envelope.MessageId, consumerType), envelope); + } + + RecordedEnvelopes.Add(envelope); + return ValueTask.CompletedTask; + } + + public ValueTask CleanupAsync(TimeSpan maxAge, CancellationToken cancellationToken) + { + return ValueTask.FromResult(0); + } + } +} diff --git a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj index 395d100ee80..a827d64686c 100644 --- a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj +++ b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Behaviors/InboxTests.cs b/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Behaviors/InboxTests.cs new file mode 100644 index 00000000000..fe6a665ae1e --- /dev/null +++ b/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Behaviors/InboxTests.cs @@ -0,0 +1,308 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Mocha.Features; +using Mocha.Inbox; +using Mocha.Middlewares; +using Mocha.Transport.RabbitMQ.Tests.Helpers; + +namespace Mocha.Transport.RabbitMQ.Tests.Behaviors; + +[Collection("RabbitMQ")] +public class InboxTests +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30); + private readonly RabbitMQFixture _fixture; + + public InboxTests(RabbitMQFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Inbox_Should_DeduplicateMessage_When_SameMessageIdPublishedTwice() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + var fixedMessageId = Guid.NewGuid().ToString(); + await using var vhost = await _fixture.CreateVhostAsync(); + + var services = new ServiceCollection(); + services.AddSingleton(vhost.ConnectionFactory); + services.AddSingleton(recorder); + services.AddSingleton(inbox); + + var builder = services + .AddMessageBus() + .AddEventHandler() + .UseInboxCore(); + + builder.ConfigureMessageBus(h => + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = fixedMessageId; + return next(ctx); + }, + "ForceMessageId"))); + + await using var bus = await builder + .AddRabbitMQ() + .BuildTestBusAsync(); + + using var scope = bus.Provider.CreateScope(); + var messageBus = scope.ServiceProvider.GetRequiredService(); + + // act - publish the same logical message twice (same MessageId forced) + await messageBus.PublishAsync(new InboxEvent { Payload = "first" }, CancellationToken.None); + + // Wait for the first message to be fully processed and recorded in the inbox + Assert.True(await recorder.WaitAsync(s_timeout), "Handler did not receive the first event within timeout"); + await WaitUntilAsync(() => inbox.RecordedEnvelopes.Count >= 1, s_timeout); + + await messageBus.PublishAsync(new InboxEvent { Payload = "second" }, CancellationToken.None); + + // assert - only the first message should be handled; the second is a duplicate + Assert.False( + await recorder.WaitAsync(TimeSpan.FromSeconds(3), expectedCount: 2), + "Handler should NOT have received the duplicate message"); + + Assert.Single(recorder.Messages); + } + + [Fact] + public async Task Inbox_Should_ProcessBothMessages_When_DifferentMessageIds() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + await using var vhost = await _fixture.CreateVhostAsync(); + await using var bus = await new ServiceCollection() + .AddSingleton(vhost.ConnectionFactory) + .AddSingleton(recorder) + .AddSingleton(inbox) + .AddMessageBus() + .AddEventHandler() + .UseInboxCore() + .AddRabbitMQ() + .BuildTestBusAsync(); + + using var scope = bus.Provider.CreateScope(); + var messageBus = scope.ServiceProvider.GetRequiredService(); + + // act - publish two distinct messages (each gets its own auto-generated MessageId) + await messageBus.PublishAsync(new InboxEvent { Payload = "msg-1" }, CancellationToken.None); + await messageBus.PublishAsync(new InboxEvent { Payload = "msg-2" }, CancellationToken.None); + + // assert - both messages should be processed + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 2), + "Handler did not receive both events within timeout"); + + Assert.Equal(2, recorder.Messages.Count); + + var payloads = recorder.Messages.Cast().Select(e => e.Payload).OrderBy(p => p).ToList(); + Assert.Equal(["msg-1", "msg-2"], payloads); + } + + [Fact] + public async Task Inbox_Should_ProcessMessage_When_SkipInboxIsSet() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + var fixedMessageId = Guid.NewGuid().ToString(); + await using var vhost = await _fixture.CreateVhostAsync(); + + var services = new ServiceCollection(); + services.AddSingleton(vhost.ConnectionFactory); + services.AddSingleton(recorder); + services.AddSingleton(inbox); + + var builder = services + .AddMessageBus() + .AddEventHandler() + .UseInboxCore(); + + builder.ConfigureMessageBus(h => + { + // Force all messages to have the same MessageId + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = fixedMessageId; + return next(ctx); + }, + "ForceMessageId")); + + // Add a consumer middleware before the inbox that sets SkipInbox + h.PrependConsume( + "Inbox", + new ConsumerMiddlewareConfiguration( + static (_, next) => + ctx => + { + var feature = ctx.Features.GetOrSet(); + feature.SkipInbox = true; + return next(ctx); + }, + "SkipInboxCheck")); + }); + + await using var bus = await builder + .AddRabbitMQ() + .BuildTestBusAsync(); + + using var scope = bus.Provider.CreateScope(); + var messageBus = scope.ServiceProvider.GetRequiredService(); + + // act - publish the same message twice; SkipInbox should bypass dedup for both + await messageBus.PublishAsync(new InboxEvent { Payload = "skip-1" }, CancellationToken.None); + await messageBus.PublishAsync(new InboxEvent { Payload = "skip-2" }, CancellationToken.None); + + // assert - both messages should be processed even though they share the same MessageId + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 2), + "Handler should receive both messages when SkipInbox is set"); + + Assert.Equal(2, recorder.Messages.Count); + } + + [Fact] + public async Task Inbox_Should_ProcessMessage_When_MessageIdIsNull() + { + // arrange + var inbox = new InMemoryMessageInbox(); + var recorder = new MessageRecorder(); + await using var vhost = await _fixture.CreateVhostAsync(); + + var services = new ServiceCollection(); + services.AddSingleton(vhost.ConnectionFactory); + services.AddSingleton(recorder); + services.AddSingleton(inbox); + + var builder = services + .AddMessageBus() + .AddEventHandler() + .UseInboxCore(); + + builder.ConfigureMessageBus(h => + h.PrependDispatch(new DispatchMiddlewareConfiguration( + (_, next) => + ctx => + { + ctx.MessageId = null; + return next(ctx); + }, + "ClearMessageId"))); + + await using var bus = await builder + .AddRabbitMQ() + .BuildTestBusAsync(); + + using var scope = bus.Provider.CreateScope(); + var messageBus = scope.ServiceProvider.GetRequiredService(); + + // act - publish two messages without MessageIds; both should pass through inbox + await messageBus.PublishAsync(new InboxEvent { Payload = "null-id-1" }, CancellationToken.None); + await messageBus.PublishAsync(new InboxEvent { Payload = "null-id-2" }, CancellationToken.None); + + // assert - both should be processed since null MessageId cannot be deduplicated + Assert.True( + await recorder.WaitAsync(s_timeout, expectedCount: 2), + "Handler should receive both messages when MessageId is null"); + + Assert.Equal(2, recorder.Messages.Count); + + // Inbox should NOT have recorded anything since MessageId was null + Assert.Empty(inbox.RecordedEnvelopes); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (!condition()) + { + await Task.Delay(50, cts.Token); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // Test types + // ══════════════════════════════════════════════════════════════════════ + + public sealed class InboxEvent + { + public required string Payload { get; init; } + } + + public sealed class InboxEventHandler(MessageRecorder recorder) : IEventHandler + { + public ValueTask HandleAsync(InboxEvent message, CancellationToken cancellationToken) + { + recorder.Record(message); + return default; + } + } + + internal sealed class InMemoryMessageInbox : IMessageInbox + { + private readonly ConcurrentDictionary<(string MessageId, string ConsumerType), MessageEnvelope> _processed = new(); + + public ConcurrentBag RecordedEnvelopes { get; } = []; + + public ValueTask ExistsAsync( + string messageId, + string consumerType, + CancellationToken cancellationToken) + { + return ValueTask.FromResult(_processed.ContainsKey((messageId, consumerType))); + } + + public ValueTask TryClaimAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken) + { + if (envelope.MessageId is null) + { + return ValueTask.FromResult(false); + } + + var claimed = _processed.TryAdd((envelope.MessageId, consumerType), envelope); + if (claimed) + { + RecordedEnvelopes.Add(envelope); + } + + return ValueTask.FromResult(claimed); + } + + public ValueTask RecordAsync( + MessageEnvelope envelope, + string consumerType, + CancellationToken cancellationToken) + { + if (envelope.MessageId is not null) + { + _processed.TryAdd((envelope.MessageId, consumerType), envelope); + } + + RecordedEnvelopes.Add(envelope); + return ValueTask.CompletedTask; + } + + public ValueTask CleanupAsync( + TimeSpan maxAge, + CancellationToken cancellationToken) + { + return ValueTask.FromResult(0); + } + } +} diff --git a/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Mocha.Transport.RabbitMQ.Tests.csproj b/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Mocha.Transport.RabbitMQ.Tests.csproj index 5d1e15def37..277af010b82 100644 --- a/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Mocha.Transport.RabbitMQ.Tests.csproj +++ b/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/Mocha.Transport.RabbitMQ.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/website/src/docs/mocha/v1/index.md b/website/src/docs/mocha/v1/index.md index 9111f5fde3f..764055911d4 100644 --- a/website/src/docs/mocha/v1/index.md +++ b/website/src/docs/mocha/v1/index.md @@ -129,9 +129,9 @@ builder.Services .AddEventHandler() .AddEntityFramework(p => { - p.AddPostgresOutbox(); - p.AddPostgresInbox(); + p.UsePostgresOutbox(); p.UseTransaction(); + p.UsePostgresInbox(); }) .AddRabbitMQ(); ``` diff --git a/website/src/docs/mocha/v1/middleware-and-pipelines.md b/website/src/docs/mocha/v1/middleware-and-pipelines.md index 2cc51981b24..418d4a1121f 100644 --- a/website/src/docs/mocha/v1/middleware-and-pipelines.md +++ b/website/src/docs/mocha/v1/middleware-and-pipelines.md @@ -64,6 +64,8 @@ PublishAsync / SendAsync / RequestAsync ┌─────────────────────────┐ │ Consumer Pipeline │ │ Instrumentation │ +│ Transaction (optional) │ +│ Inbox (optional) │ │ → Your Handler │ └─────────────────────────┘ ``` @@ -308,11 +310,12 @@ Middleware can also be registered at transport or endpoint scope. Bus-level midd The built-in middleware in the receive pipeline implements the reliability and observability features described on their own pages: +- The `Inbox` middleware deduplicates incoming messages based on `MessageId`, described in [Reliability](/docs/mocha/v1/reliability#deduplicate-messages-with-the-transactional-inbox). It runs in the **consumer pipeline** after the transaction middleware so that the inbox claim participates in the same database transaction as the handler's business data. Use `PrependConsume` and `AppendConsume` with the `"Inbox"` key to position your middleware relative to it. - The `CircuitBreaker` and `DeadLetter` middleware implement the circuit breaker and dead-letter behaviors described in [Reliability](/docs/mocha/v1/reliability). Use `PrependReceive` and `AppendReceive` with their keys to position your middleware relative to them. - The `ReceiveInstrumentation` middleware generates the OpenTelemetry spans and metrics described in [Observability](/docs/mocha/v1/observability). Place logging or correlation middleware after `ReceiveInstrumentation` so telemetry context is available. # Next steps -The pipeline handles failures automatically. Learn how circuit breaking, dead-letter routing, and the transactional outbox work in [Reliability](/docs/mocha/v1/reliability). +The pipeline handles failures automatically. Learn how circuit breaking, dead-letter routing, the transactional outbox, and the idempotent inbox work in [Reliability](/docs/mocha/v1/reliability). > **Runnable examples:** [CustomMiddleware](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Examples/Middleware/CustomMiddleware), [UnitOfWork](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Examples/Middleware/UnitOfWork) diff --git a/website/src/docs/mocha/v1/reliability.md b/website/src/docs/mocha/v1/reliability.md index e78d1040ef0..84a9251ae88 100644 --- a/website/src/docs/mocha/v1/reliability.md +++ b/website/src/docs/mocha/v1/reliability.md @@ -1,6 +1,6 @@ --- title: "Reliability" -description: "Configure fault handling, dead-letter routing, message expiry, concurrency limits, circuit breakers, and the transactional outbox in Mocha to build resilient messaging pipelines." +description: "Configure fault handling, dead-letter routing, message expiry, concurrency limits, circuit breakers, the transactional outbox, and the idempotent inbox in Mocha to build resilient messaging pipelines." --- Messaging systems fail. Handlers throw exceptions, brokers go offline, databases lock up, and messages arrive faster than consumers can process them. Mocha's reliability features handle these failures at the infrastructure level so your handler code stays focused on business logic. @@ -17,24 +17,26 @@ builder.Services .AddEventHandler() .AddEntityFramework(p => { - p.AddPostgresOutbox(); + p.UsePostgresOutbox(); p.UseTransaction(); + p.UsePostgresInbox(); }) .AddRabbitMQ(); ``` -That configuration adds circuit breaking, concurrency limiting, transactional outbox, and database transaction wrapping - all as middleware in the receive and dispatch pipelines. +That configuration adds circuit breaking, concurrency limiting, transactional outbox, idempotent inbox, and database transaction wrapping - all as middleware in the receive and dispatch pipelines. # Delivery guarantees -The outbox changes what delivery guarantee your system provides: +The outbox and inbox change what delivery guarantee your system provides: -| Configuration | Guarantee | What it means | -| -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| Without outbox | At-most-once | A message may be lost if the broker or handler crashes after receipt but before processing completes. | -| With outbox | At-least-once | Every message is persisted before dispatch. Your handlers may be invoked more than once if a crash occurs between dispatch and acknowledgment. | +| Configuration | Guarantee | What it means | +| ------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Without outbox | At-most-once | A message may be lost if the broker or handler crashes after receipt but before processing completes. | +| With outbox | At-least-once | Every message is persisted before dispatch. Your handlers may be invoked more than once if a crash occurs between dispatch and acknowledgment. | +| With outbox + inbox | Effectively exactly-once | The outbox guarantees every message is delivered. The inbox deduplicates on the receiving side, so each message is processed exactly once. | -At-least-once delivery is the right default for most production systems. It shifts the burden from "did this message arrive?" to "is my handler safe to run twice?" Design handlers to be idempotent. +At-least-once delivery is the right default for most production systems. Adding the inbox on the consumer side upgrades the guarantee to effectively exactly-once processing - the outbox ensures delivery and the inbox ensures your handler runs only once per message. If you use the outbox without the inbox, design handlers to be idempotent. # The receive pipeline and failure flow @@ -51,10 +53,13 @@ TransportCircuitBreaker -> MessageTypeSelection -> Routing -> Consumer pipeline - -> Your handler + -> Transaction middleware (BEGIN) + -> Inbox (claim inside transaction) + -> Your handler + -> Transaction middleware (COMMIT/ROLLBACK) ``` -Each middleware can intercept failures from downstream, transform them, or short-circuit the pipeline. The reliability middlewares - dead-letter, fault, circuit breaker, expiry, and concurrency limiter - are all enabled by default with sensible defaults. You tune them when the defaults do not match your workload. +Each middleware can intercept failures from downstream, transform them, or short-circuit the pipeline. The reliability middlewares - dead-letter, fault, circuit breaker, expiry, inbox, and concurrency limiter - are all enabled by default with sensible defaults. You tune them when the defaults do not match your workload. # Handle faults @@ -290,7 +295,7 @@ builder.Services .AddEventHandler() .AddEntityFramework(p => { - p.AddPostgresOutbox(); + p.UsePostgresOutbox(); p.UseTransaction(); }) .AddRabbitMQ(); @@ -299,7 +304,7 @@ builder.Services | Call | Purpose | | -------------------------------- | -------------------------------------------------------------------------------------------------- | | `AddEntityFramework()` | Registers your DbContext with the bus for persistence features. | -| `AddPostgresOutbox()` | Registers the Postgres outbox processor, background worker, and `IMessageOutbox`. | +| `UsePostgresOutbox()` | Registers the Postgres outbox processor, background worker, and `IMessageOutbox`. | | `UseTransaction()` | Wraps each consumer invocation in a database transaction (commit on success, rollback on failure). | **4. Publish inside a transaction.** @@ -331,7 +336,7 @@ public class OrderPlacedHandler(AppDbContext db, IMessageBus bus) After the transaction commits, the outbox processor detects the new message (via EF Core interceptors that signal on save and transaction commit) and dispatches it to the transport. :::note Idempotency requirement -The outbox guarantees at-least-once delivery. Your handlers may be invoked more than once for the same message if the outbox dispatches successfully but the transport acknowledgment is lost before the message is deleted from the outbox table. Design handlers to be idempotent. See the [Idempotent Consumer](https://microservices.io/patterns/communication-style/idempotent-consumer.html) pattern for strategies. +The outbox guarantees at-least-once delivery. Your handlers may be invoked more than once for the same message if the outbox dispatches successfully but the transport acknowledgment is lost before the message is deleted from the outbox table. You can handle this in two ways: design handlers to be idempotent manually (see the [Idempotent Consumer](https://microservices.io/patterns/communication-style/idempotent-consumer.html) pattern), or enable the [inbox](#deduplicate-messages-with-the-transactional-inbox) on the receiving side to let Mocha deduplicate automatically. ::: ## Use execution strategy resilience @@ -344,7 +349,7 @@ builder.Services .AddEventHandler() .AddEntityFramework(p => { - p.AddPostgresOutbox(); + p.UsePostgresOutbox(); p.UseResilience(); // Wraps consumer execution with the EF Core execution strategy p.UseTransaction(); }) @@ -365,9 +370,188 @@ Some messages - like internal system events or replies that do not need durabili The outbox middleware also only intercepts messages of kind `Publish`, `Send`, `Reply`, or `Fault`. Other message kinds pass through without outbox persistence. +# Deduplicate messages with the transactional inbox + +The transactional outbox guarantees at-least-once delivery. The transactional inbox completes the picture: it provides exactly-once processing by deduplicating messages on the receiving side. + +When a transport redelivers a message - because of a broker retry, a network hiccup, or an outbox re-dispatch - the inbox detects the duplicate `MessageId` and silently skips it. Your handler never runs twice for the same message. + +Deduplication is scoped per consumer type: when a message is routed to multiple handlers, each handler independently claims and processes the message. The inbox uses a composite key of `(MessageId, ConsumerType)` so that handler A and handler B each process the same message exactly once, even though they share the same inbox table. + +```mermaid +sequenceDiagram + participant T as Transport + participant TX as Transaction Middleware + participant I as Inbox Middleware + participant DB as Inbox Table + participant H as Handler + + T->>TX: Deliver message (MessageId: abc-123) + TX->>TX: BEGIN TRANSACTION + TX->>I: Pass to inbox + I->>DB: TryClaimAsync("abc-123", "OrderPlacedHandler") [inside transaction] + DB-->>I: true (claimed) + I->>H: Process message + H->>DB: Write business data [same transaction] + H-->>I: Handler completed + I-->>TX: Return + TX->>TX: COMMIT (inbox claim + business data atomic) + Note over T,DB: Later, transport redelivers the same message + T->>TX: Deliver message (MessageId: abc-123) + TX->>TX: BEGIN TRANSACTION + TX->>I: Pass to inbox + I->>DB: TryClaimAsync("abc-123", "OrderPlacedHandler") [inside transaction] + DB-->>I: false (already claimed) + I-->>TX: Skip (message not processed again) + TX->>TX: COMMIT (no-op) +``` + +The inbox middleware runs in the **consumer pipeline**, after the transaction middleware. This means the inbox claim INSERT participates in the same database transaction as the handler's business data. Both commit or rollback atomically: if the process crashes between the claim and the commit, the claim is rolled back and the message can be safely redelivered. + +Messages without a `MessageId` pass through the inbox without deduplication - there is no identifier to check against. + +See [Idempotent Consumer](https://microservices.io/patterns/communication-style/idempotent-consumer.html) for the canonical description of this pattern. + +## Set up the Postgres inbox + +**1. Add the NuGet packages.** + +```bash +dotnet add package Mocha.EntityFrameworkCore +dotnet add package Mocha.EntityFrameworkCore.Postgres +``` + +These are the same packages used by the outbox. If you already have them installed for the outbox, skip this step. + +**2. Add the `InboxMessage` entity to your DbContext.** + +```csharp +using Microsoft.EntityFrameworkCore; +using Mocha.Inbox; + +public class AppDbContext : DbContext +{ + public DbSet InboxMessages => Set(); + + // Your existing DbSets + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.AddPostgresInbox(); + } +} +``` + +`InboxMessage` has four columns: `MessageId` (string), `ConsumerType` (string, the handler type name), `MessageType` (string, nullable, for diagnostics), and `ProcessedAt` (DateTime, defaults to `NOW()`). The primary key is the composite `(MessageId, ConsumerType)`, enabling per-handler deduplication when a message is routed to multiple consumers. + +**3. Register the inbox middleware.** + +```csharp +builder.Services + .AddMessageBus() + .AddEventHandler() + .AddEntityFramework(p => + { + p.UseTransaction(); + p.UsePostgresInbox(); + }) + .AddRabbitMQ(); +``` + +| Call | Purpose | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `AddEntityFramework()` | Registers your DbContext with the bus for persistence features. | +| `UsePostgresInbox()` | Registers the Postgres inbox, background cleanup worker, and `IMessageInbox`. Inserts the inbox consumer middleware. | +| `UseTransaction()` | Wraps each consumer invocation in a database transaction (commit on success, rollback on failure). | + +**4. Combine outbox and inbox for full exactly-once processing.** + +When you use both together, the outbox guarantees delivery and the inbox guarantees deduplication: + +```csharp +builder.Services + .AddMessageBus() + .AddEventHandler() + .AddEntityFramework(p => + { + p.UsePostgresOutbox(); + p.UseTransaction(); + p.UsePostgresInbox(); + }) + .AddRabbitMQ(); +``` + +With this configuration, the inbox record and your business data are committed in the same database transaction. If the transaction rolls back, the inbox entry is not persisted and the message can be reprocessed on the next delivery attempt. + +## Configure inbox retention + +The inbox stores a record for every processed message. A background worker (`MessageBusInboxWorker`) periodically cleans up old records to prevent unbounded table growth. + +```csharp +builder.Services + .AddMessageBus() + .AddEventHandler() + .AddEntityFramework(p => + { + p.UseTransaction(); + p.UsePostgresInbox(opts => + { + opts.RetentionPeriod = TimeSpan.FromDays(14); + opts.CleanupInterval = TimeSpan.FromMinutes(30); + }); + }) + .AddRabbitMQ(); +``` + +| Option | Default | Description | +| ----------------- | ------- | -------------------------------------------------------------------------------------------------------- | +| `RetentionPeriod` | 7 days | How long processed message records are kept. Messages older than this are deleted by the cleanup worker. | +| `CleanupInterval` | 1 hour | How often the background worker runs the cleanup sweep. | + +Set `RetentionPeriod` long enough to cover the maximum redelivery window of your transport. If your transport can redeliver messages up to 3 days after initial delivery, a 7-day retention period provides a comfortable safety margin. + +The cleanup worker deletes expired rows in batches to avoid long-running locks on the inbox table. + +## Skip the inbox for specific messages + +Some messages do not need deduplication - internal system events, heartbeats, or messages from transports that guarantee exactly-once delivery natively. The inbox middleware checks for an `InboxMiddlewareFeature` on the consume context. Messages with `SkipInbox = true` pass straight through without an inbox lookup. + +Set the feature from a custom consumer middleware that runs before the inbox: + +```csharp +builder.Services + .AddMessageBus(bus => + { + bus.PrependConsume( + "Inbox", + new ConsumerMiddlewareConfiguration( + static (_, next) => ctx => + { + var feature = ctx.Features.GetOrSet(); + feature.SkipInbox = true; + return next(ctx); + }, + "SkipInboxCheck")); + }) + .AddEventHandler() + .AddEntityFramework(p => + { + p.UseTransaction(); + p.UsePostgresInbox(); + }) + .AddRabbitMQ(); +``` + +`PrependConsume("Inbox", ...)` inserts your middleware immediately before the inbox middleware in the consumer pipeline. The `InboxMiddlewareFeature` is a pooled feature that resets automatically between messages. + +## How the inbox cleanup worker works + +The inbox cleanup worker is a background hosted service (`IHostedService`). It runs in a continuous loop: wait for `CleanupInterval`, then delete all inbox records where `ProcessedAt` is older than `RetentionPeriod`. Deletions happen in batches to minimize lock contention. The worker logs at `Information` level when records are deleted and at `Error` level if cleanup fails. + # Next steps -Your messaging pipeline now handles failures, limits concurrency, breaks circuits on sustained errors, and guarantees delivery through the outbox. To monitor your messaging system, see [Observability](/docs/mocha/v1/observability). +Your messaging pipeline now handles failures, limits concurrency, breaks circuits on sustained errors, guarantees delivery through the outbox, and deduplicates messages through the inbox. To monitor your messaging system, see [Observability](/docs/mocha/v1/observability). - [**Middleware and Pipelines**](/docs/mocha/v1/middleware-and-pipelines) - Write custom middleware, control pipeline ordering, and understand the three pipeline stages. - [**Sagas**](/docs/mocha/v1/sagas) - Coordinate multi-step workflows with state machine sagas that use compensation when steps fail. @@ -375,4 +559,4 @@ Your messaging pipeline now handles failures, limits concurrency, breaks circuit > **Runnable examples:** [OutboxInbox](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Examples/Reliability/OutboxInbox), [CircuitBreaker](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Examples/Reliability/CircuitBreaker) > -> **Full demo:** All three Demo services ([Catalog](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Catalog), [Billing](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Billing), [Shipping](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Shipping)) use the PostgreSQL transactional outbox with `UseTransaction()` and `UseResilience()` for reliable message delivery. +> **Full demo:** All three Demo services ([Catalog](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Catalog), [Billing](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Billing), [Shipping](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Shipping)) use the PostgreSQL transactional outbox and inbox with `UseTransaction()` and `UseResilience()` for reliable, exactly-once message processing. diff --git a/website/src/docs/mocha/v1/routing-and-endpoints.md b/website/src/docs/mocha/v1/routing-and-endpoints.md index acf6caaf07a..0fd04412811 100644 --- a/website/src/docs/mocha/v1/routing-and-endpoints.md +++ b/website/src/docs/mocha/v1/routing-and-endpoints.md @@ -314,5 +314,5 @@ The middleware pipeline is compiled per-endpoint from the same three layers: bus Your routing and endpoint configuration is set. From here: - [**Middleware and Pipelines**](/docs/mocha/v1/middleware-and-pipelines) - Write custom middleware, control pipeline ordering, and understand how the three pipeline stages interact. Want to customize the processing pipeline? That's the next page. -- [**Reliability**](/docs/mocha/v1/reliability) - Configure fault handling, circuit breakers, concurrency limits, and the transactional outbox. +- [**Reliability**](/docs/mocha/v1/reliability) - Configure fault handling, circuit breakers, concurrency limits, the transactional outbox, and the idempotent inbox. - [**Transports**](/docs/mocha/v1/transports) - Dive into transport-specific configuration for RabbitMQ and InMemory. diff --git a/website/src/docs/mocha/v1/transports/rabbitmq.md b/website/src/docs/mocha/v1/transports/rabbitmq.md index 15837b95361..84815b67154 100644 --- a/website/src/docs/mocha/v1/transports/rabbitmq.md +++ b/website/src/docs/mocha/v1/transports/rabbitmq.md @@ -432,8 +432,8 @@ All auto-provisioned resources are durable by default and survive broker restart - [Transports Overview](/docs/mocha/v1/transports) - Understand the transport abstraction and lifecycle. - [Handlers and Consumers](/docs/mocha/v1/handlers-and-consumers) - Learn about handler types and consumer configuration. -- [Reliability](/docs/mocha/v1/reliability) - Configure dead-letter routing, outbox, and fault handling. +- [Reliability](/docs/mocha/v1/reliability) - Configure dead-letter routing, outbox, inbox, and fault handling. > **Runnable example:** [RabbitMQ](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Examples/Transports/RabbitMQ) > -> **Full demo:** All three Demo services use RabbitMQ in production mode with .NET Aspire. See [Demo.AppHost](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.AppHost) for the Aspire orchestration and [Demo.Catalog](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Catalog) for a complete service using `.AddRabbitMQ()` with outbox, sagas, and multiple handler types. +> **Full demo:** All three Demo services use RabbitMQ in production mode with .NET Aspire. See [Demo.AppHost](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.AppHost) for the Aspire orchestration and [Demo.Catalog](https://github.com/ChilliCream/graphql-platform/tree/main/src/Mocha/src/Demo/Demo.Catalog) for a complete service using `.AddRabbitMQ()` with outbox, inbox, sagas, and multiple handler types.