diff --git a/src/Marten/Events/Schema/EventsTable.cs b/src/Marten/Events/Schema/EventsTable.cs index 52598492c1..41be1795c1 100644 --- a/src/Marten/Events/Schema/EventsTable.cs +++ b/src/Marten/Events/Schema/EventsTable.cs @@ -69,14 +69,40 @@ public EventsTable(EventGraph events): base(new PostgresqlObjectName(events.Data if (events.TenancyStyle == TenancyStyle.Conjoined) { + // #4606: under UseTenantPartitionedEvents, mt_events is itself a partitioned + // table (by tenant_id) and so is mt_streams. Declaring a parent-level FK from + // mt_events → mt_streams makes Postgres auto-propagate a partition-targeting + // FK (mt_events → mt_streams_) into mt_events as an INHERITED constraint + // the moment the first mt_streams_ partition is attached. The next + // mt_events_ partition-attach then trips + // `42P16: cannot drop inherited constraint` because Weasel's + // additivelyMigrateTablesForNewPartitions treats the auto-propagated FK as an + // "extra" (not in Marten's declared FK set) and tries to drop it, which + // Postgres refuses on an inherited constraint. The downstream symptom is + // 23514 on the first event append because the partition attach was silently + // swallowed by the migration's catch block. + // + // Skipping the explicit FK trades database-level referential integrity from + // mt_events to mt_streams for the per-tenant-partitioning combination + // working at all. Marten's append path always inserts/updates the stream row + // before persisting events, so application-level integrity is preserved; + // external tooling that writes mt_events directly without ensuring the + // stream exists in mt_streams is the only at-risk path, and that's already + // outside Marten's contract. The non-partitioned and archived-partitioning + // shapes keep their FK because they don't trigger the auto-propagation. + var skipMtEventsFkForTenantPartitioning = events.UseTenantPartitionedEvents; + if (events.UseArchivedStreamPartitioning) { - ForeignKeys.Add(new ForeignKey("fkey_mt_events_stream_id_tenant_id_is_archived") + if (!skipMtEventsFkForTenantPartitioning) { - ColumnNames = new[] { TenantIdColumn.Name, "stream_id", "is_archived" }, - LinkedNames = new[] { TenantIdColumn.Name, "id", "is_archived" }, - LinkedTable = new PostgresqlObjectName(events.DatabaseSchemaName, "mt_streams") - }); + ForeignKeys.Add(new ForeignKey("fkey_mt_events_stream_id_tenant_id_is_archived") + { + ColumnNames = new[] { TenantIdColumn.Name, "stream_id", "is_archived" }, + LinkedNames = new[] { TenantIdColumn.Name, "id", "is_archived" }, + LinkedTable = new PostgresqlObjectName(events.DatabaseSchemaName, "mt_streams") + }); + } Indexes.Add(new IndexDefinition("pk_mt_events_stream_and_version") { @@ -85,12 +111,15 @@ public EventsTable(EventGraph events): base(new PostgresqlObjectName(events.Data } else { - ForeignKeys.Add(new ForeignKey("fkey_mt_events_stream_id_tenant_id") + if (!skipMtEventsFkForTenantPartitioning) { - ColumnNames = new[] { TenantIdColumn.Name, "stream_id" }, - LinkedNames = new[] { TenantIdColumn.Name, "id" }, - LinkedTable = new PostgresqlObjectName(events.DatabaseSchemaName, "mt_streams") - }); + ForeignKeys.Add(new ForeignKey("fkey_mt_events_stream_id_tenant_id") + { + ColumnNames = new[] { TenantIdColumn.Name, "stream_id" }, + LinkedNames = new[] { TenantIdColumn.Name, "id" }, + LinkedTable = new PostgresqlObjectName(events.DatabaseSchemaName, "mt_streams") + }); + } Indexes.Add(new IndexDefinition("pk_mt_events_stream_and_version") { diff --git a/src/MultiTenancyTests/sharded_eager_apply_per_tenant_events_tests.cs b/src/MultiTenancyTests/sharded_eager_apply_per_tenant_events_tests.cs new file mode 100644 index 0000000000..1224c5566b --- /dev/null +++ b/src/MultiTenancyTests/sharded_eager_apply_per_tenant_events_tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx; +using JasperFx.Events; +using JasperFx.MultiTenancy; +using Marten; +using Marten.Events; +using Marten.Storage; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Npgsql; +using Shouldly; +using Weasel.Core; +using Weasel.Postgresql; +using Xunit; + +namespace MultiTenancyTests; + +/// +/// #4606 regression — eager schema apply on a sharded + per-tenant-events store, +/// followed by runtime tenant provisioning, must succeed (no 42P16 on the partition +/// attach's FK drop). +/// +/// +/// The shape this exercises is the production startup pattern: +/// ApplyAllConfiguredChangesToDatabaseAsync() at boot → tenant arrives later +/// → AddTenantToShardAsync(tenantId) → first append. +/// In 9.4.x the FK on the parent mt_events table is inherited the moment +/// the first partition is attached, and Weasel's partition-attach drop-and-recreate +/// of that FK trips Postgres' 42P16 (cannot drop inherited constraint). #4598's +/// tests deliberately exercised the lazy-apply flow to dodge this; #4606 is the +/// eager-apply variant. +/// +/// +[Collection("sharded-tenancy")] +public class sharded_eager_apply_per_tenant_events_tests : IAsyncLifetime +{ + private readonly ShardedTenancyFixture _fixture; + private IDocumentStore _store = null!; + + public sharded_eager_apply_per_tenant_events_tests(ShardedTenancyFixture fixture) + { + _fixture = fixture; + } + + public async Task InitializeAsync() + { + await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + try { await conn.DropSchemaAsync("sharded"); } catch { } + + foreach (var connStr in _fixture.ConnectionStrings.Values) + { + await using var tenantConn = new NpgsqlConnection(connStr); + await tenantConn.OpenAsync(); + try { await tenantConn.DropSchemaAsync("tenants"); } catch { } + await ShardedTenancyFixture.cleanMartenObjectsInPublicSchema(tenantConn); + } + } + + public Task DisposeAsync() + { + _store?.Dispose(); + return Task.CompletedTask; + } + + private IDocumentStore CreateStore() + { + _store = DocumentStore.For(opts => + { + opts.MultiTenantedWithShardedDatabases(x => + { + x.ConnectionString = ConnectionSource.ConnectionString; + x.SchemaName = "sharded"; + x.PartitionSchemaName = "tenants"; + foreach (var (dbName, connStr) in _fixture.ConnectionStrings) + { + x.AddDatabase(dbName, connStr); + } + x.UseSmallestDatabaseAssignment(); + }); + + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Events.AppendMode = EventAppendMode.QuickWithServerTimestamps; + opts.Events.UseTenantPartitionedEvents = true; + opts.Events.AddEventType(); + }); + + return _store; + } + + [Fact] + public async Task eager_apply_then_AddTenantToShardAsync_then_append_succeeds() + { + CreateStore(); + + // The eager-apply pattern: create the parent partitioned schema on every + // shard at startup time, BEFORE any tenant is provisioned. This is the + // startup-migrate deployment shape that #4606 needs to support. + var databases = await _store.Options.Tenancy.BuildDatabases(); + foreach (var db in databases.OfType()) + { + await db.ApplyAllConfiguredChangesToDatabaseAsync(); + } + + // Runtime tenant provisioning hits the partition-attach path against an + // already-created parent mt_events. Master fails here with + // 42P16 cannot drop inherited constraint "mt_events_tenant_id_stream_id_fkey". + var dbId = await _store.Advanced.AddTenantToShardAsync("alpha", CancellationToken.None); + dbId.ShouldNotBeNull(); + + // The actual user-facing failure surface: first append for the new tenant. + // On master this throws either the same 42P16 (the attach was silently + // swallowed and the partition is missing) or a downstream 23514 (no + // partition of relation mt_events found for row). + await using var session = _store.LightweightSession("alpha"); + session.Events.StartStream(Guid.NewGuid(), new ShardedTestEvent { Value = "eager-apply-then-tenant" }); + await session.SaveChangesAsync(); + + // Sanity: the partition lives in the assigned shard. + await using var conn = new NpgsqlConnection(_fixture.ConnectionStrings[dbId]); + await conn.OpenAsync(); + var tables = await conn.ExistingTablesAsync(); + tables.Any(t => t.Name == "mt_events_alpha").ShouldBeTrue( + $"mt_events_alpha partition must exist after eager-apply + runtime tenant. Tables: {string.Join(", ", tables.Select(t => t.QualifiedName))}"); + } +}