Skip to content

fix(marten): publish IEvent.TenantId verbatim under conjoined tenancy#2693

Merged
jeremydmiller merged 2 commits intomainfrom
publishing-relay-conjoined-default-tenant
May 7, 2026
Merged

fix(marten): publish IEvent.TenantId verbatim under conjoined tenancy#2693
jeremydmiller merged 2 commits intomainfrom
publishing-relay-conjoined-default-tenant

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Closes #2675. Supersedes #2676.

Summary

  • Under conjoined tenancy PublishingRelay's default-tenant fallthrough lets WolverineSubscriptionRunner's bus.TenantId (= operations.Database.Identifier) leak onto the outbound envelope. Default-tenant events were dispatched under the database identifier (e.g. "Main") instead of *DEFAULT*, the handler chain ran under the wrong tenant scope (or silently no-op'd), but Marten still advanced the subscription sequence because publishing succeeded.
  • This PR keeps Fix PublishingRelay misrouting default-tenant events under conjoined tenancy #2676's two regression tests but simplifies the production code so the hot loop in ProcessEventsAsync has no TenancyStyle comparison and no downcast through IDocumentSession.DocumentStore.

Why this is simpler than #2676

#2676 added a per-event runtime check inside the loop:

var tenancyStyle = ((IDocumentSession)operations).DocumentStore.Options.Events.TenancyStyle; // downcast
if (e.TenantId != StorageConstants.DefaultTenantId || tenancyStyle == TenancyStyle.Conjoined) // runtime check
    await bus.PublishAsync(e, new DeliveryOptions { TenantId = e.TenantId });
else
    await bus.PublishAsync(e);

This PR binds the publish path once at construction by capturing TenancyStyle from the surrounding ConfigureMarten lambda and picking between two static method-group delegates:

public PublishingRelay(string subscriptionName, TenancyStyle tenancyStyle) : base(subscriptionName)
{
    _relay = tenancyStyle == TenancyStyle.Conjoined
        ? RelayWithEventTenant
        : RelayWithLegacyFallthrough;
}

// hot path in ProcessEventsAsync:
await _relay(e, bus);

private static ValueTask RelayWithEventTenant(IEvent e, IMessageBus bus)
    => bus.PublishAsync(e, new DeliveryOptions { TenantId = e.TenantId });

private static ValueTask RelayWithLegacyFallthrough(IEvent e, IMessageBus bus)
    => e.TenantId != StorageConstants.DefaultTenantId
        ? bus.PublishAsync(e, new DeliveryOptions { TenantId = e.TenantId })
        : bus.PublishAsync(e);

The downcast is gone entirely. The conjoined hot path has zero runtime checks. The legacy path keeps its single comparison — that one's intrinsic to its semantics, not avoidable without changing behaviour.

Compatibility

Setup Pre-fix Envelope.TenantId Post-fix Envelope.TenantId
No tenancy (default Marten store) Database.Identifier Database.Identifier (unchanged)
Conjoined, default-tenant event Database.Identifier (the bug) *DEFAULT* (correct)
Conjoined, named-tenant event "one" "one" (unchanged)
MultiTenantedDatabases(...AddSingleTenantDatabase(...)) "tenant1" "tenant1" (unchanged)
Per-tenant ancillary stores with custom Database.Identifier (TenancyStyle.Single) the custom identifier the custom identifier (unchanged)

Only the conjoined-default-tenant cell changes. Every other row keeps its pre-fix behaviour because the relay only switches into RelayWithEventTenant mode under conjoined.

Tests (carried over from #2676)

  1. carry_default_tenant_id_through_under_conjoined_tenancy — publishes a default-tenant event through a conjoined store; asserts Envelope.TenantId == StorageConstants.DefaultTenantId. Reproduces the bug on main.
  2. non_conjoined_store_preserves_legacy_default_tenant_fallthrough — companion on a non-conjoined store; asserts the relay still falls through, guarding the per-tenant-ancillary-store pattern against accidental over-application.

Test plan

  • dotnet test src/Persistence/MartenSubscriptionTests --framework net9.012/12 passed (both regression tests included)
  • dotnet build src/Persistence/MartenTests clean (the PublishingRelay consumers in the broader Marten test project still compile)

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits May 7, 2026 07:21
Under conjoined tenancy `PublishingRelay`'s default-tenant fallthrough
let `WolverineSubscriptionRunner`'s `bus.TenantId`
(= `operations.Database.Identifier`) leak onto the outbound envelope.
Default-tenant events were dispatched under the database identifier
(e.g. `"Main"`) instead of `*DEFAULT*`, the handler chain ran under the
wrong tenant scope (or silently no-op'd), but Marten still advanced the
subscription sequence because publishing succeeded. Closes GH-2675.

Supersedes #2676 with a simpler implementation: bind the per-event
publish path once at construction time so the hot loop in
`ProcessEventsAsync` needs neither a `TenancyStyle` comparison nor a
downcast through `IDocumentSession.DocumentStore`. The constructor now
takes the `TenancyStyle` from the surrounding `ConfigureMarten` lambda
and selects between two static method-group delegates:

- `RelayWithEventTenant` (conjoined): always propagates `e.TenantId`
  verbatim — including `StorageConstants.DefaultTenantId` for
  default-tenant events.
- `RelayWithLegacyFallthrough` (everything else): preserves the
  existing fallthrough so setups that legitimately use the database
  identifier as the message tenant (e.g. per-tenant ancillary stores
  with `TenancyStyle.Single`) keep working.

Compatibility table — only the conjoined-default-tenant cell changes:

| Setup                                            | Pre-fix `Envelope.TenantId` | Post-fix `Envelope.TenantId` |
|--------------------------------------------------|------------------------------|--------------------------------|
| No tenancy (default Marten store)                | `Database.Identifier`        | `Database.Identifier` (unchanged) |
| Conjoined, default-tenant event                  | `Database.Identifier` (bug)  | `*DEFAULT*` (correct) |
| Conjoined, named-tenant event                    | `"one"`                      | `"one"` (unchanged) |
| `MultiTenantedDatabases(...)` per-tenant DB      | `"tenant1"`                  | `"tenant1"` (unchanged) |
| Per-tenant ancillary store, `TenancyStyle.Single`| custom identifier            | custom identifier (unchanged) |

Tests in `MartenSubscriptionTests` (carried over from #2676):

1. `carry_default_tenant_id_through_under_conjoined_tenancy` —
   publishes a default-tenant event through a conjoined store; asserts
   `Envelope.TenantId == StorageConstants.DefaultTenantId`. Reproduces
   the bug on main.
2. `non_conjoined_store_preserves_legacy_default_tenant_fallthrough` —
   companion test on a non-conjoined store; asserts the relay still
   falls through to the bus context's tenant id, guarding the
   per-tenant-ancillary-store pattern against accidental over-application.

Both pass; full `MartenSubscriptionTests` suite is 12/12 green on
net9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#2693 supersedes #2676. The original tests + bug analysis are
@svenclaesson's work; this trailer attaches the attribution that the
amended trailer on the prior commit couldn't carry because
force-push is disabled by the branch protection rule.

Co-Authored-By: Sven Claesson <sven.claesson@devshift.se>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PublishingRelay drops TenantId for default-tenant events, misrouting them in conjoined tenancy

1 participant