Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions src/Marten/Events/Schema/EventsTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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_<tenant>) into mt_events as an INHERITED constraint
// the moment the first mt_streams_<tenant> partition is attached. The next
// mt_events_<tenant> 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")
{
Expand All @@ -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")
{
Expand Down
131 changes: 131 additions & 0 deletions src/MultiTenancyTests/sharded_eager_apply_per_tenant_events_tests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #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).
///
/// <para>
/// The shape this exercises is the production startup pattern:
/// <c>ApplyAllConfiguredChangesToDatabaseAsync()</c> at boot → tenant arrives later
/// → <c>AddTenantToShardAsync(tenantId)</c> → first append.
/// In 9.4.x the FK on the parent <c>mt_events</c> 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.
/// </para>
/// </summary>
[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<ShardedTestEvent>();
});

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<IMartenDatabase>())
{
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))}");
}
}
Loading