diff --git a/src/content/docs/building-blocks/eventing-abstractions.mdx b/src/content/docs/building-blocks/eventing-abstractions.mdx index 04f7b874..47802725 100644 --- a/src/content/docs/building-blocks/eventing-abstractions.mdx +++ b/src/content/docs/building-blocks/eventing-abstractions.mdx @@ -1,6 +1,6 @@ --- title: Eventing.Abstractions building block -lastUpdated: 2026-06-11 +lastUpdated: 2026-11-05 description: Dependency-free contracts for cross-module and cross-service events - IIntegrationEvent, IIntegrationEventHandler, IEventBus, IEventSerializer. sidebar: label: Eventing.Abstractions @@ -57,10 +57,13 @@ public interface IEventSerializer public interface IEventTenantScope { IDisposable Begin(string? tenantId); + + // Default implementation: using (Begin(tenantId)) await action(ct); + Task ExecuteAsync(string? tenantId, Func action, CancellationToken ct = default); } ``` -`IEventTenantScope` is the piece that makes background publishing safe in a multi-tenant world: the bus calls `Begin(event.TenantId)` **before** resolving handlers, because a `MultiTenantDbContext` captures its tenant at construction time. The default implementation is a no-op; the Multitenancy module registers a Finbuckle-backed one. +`IEventTenantScope` is the piece that makes background publishing safe in a multi-tenant world: the bus runs its handler loop inside `ExecuteAsync(event.TenantId, ...)` **before** resolving handlers, because a `MultiTenantDbContext` captures its tenant at construction time. The callback shape lets an implementation do asynchronous work, such as a tenant lookup, in the same async flow as the handlers. The default `ExecuteAsync` wraps `Begin`, so existing implementations keep working. The default scope is a no-op; the Multitenancy module registers a Finbuckle-backed one that installs the full tenant, including a dedicated connection string. See [source and consumer routing](/docs/building-blocks/eventing/#source-and-consumer-routing). ## Pattern: where each interface lives diff --git a/src/content/docs/building-blocks/eventing.mdx b/src/content/docs/building-blocks/eventing.mdx index bd32c014..45edf172 100644 --- a/src/content/docs/building-blocks/eventing.mdx +++ b/src/content/docs/building-blocks/eventing.mdx @@ -1,6 +1,6 @@ --- title: Eventing building block -lastUpdated: 2026-11-03 +lastUpdated: 2026-11-05 description: Event bus implementation (InMemory or RabbitMQ), outbox/inbox stores for durable delivery, and the open-generic handler registration that lets modules subscribe with one method. sidebar: label: Eventing @@ -27,7 +27,7 @@ Publish goes through the outbox in the same transaction as your business write. ### Event bus implementations -- **`InMemoryEventBus`** - in-process. For each event it **sets the tenant context first** (`IEventTenantScope.Begin(event.TenantId)`), creates a fresh DI scope, resolves the matching handlers, checks the inbox (skip if already processed by that handler), awaits each handler in order, and marks the inbox row for each one that succeeds. A handler that throws doesn't stop the ones after it: every handler runs, then the failure is rethrown (an `AggregateException` when several fail) so the outbox retries the event, and the inbox skips the handlers that already succeeded. Great for dev/test and single-process production hosts. +- **`InMemoryEventBus`** - in-process. For each event it **sets the tenant context first** (`IEventTenantScope.ExecuteAsync(event.TenantId, ...)`), creates a fresh DI scope inside that callback, resolves the matching handlers, checks the inbox (skip if already processed by that handler), awaits each handler in order, and marks the inbox row for each one that succeeds. A handler that throws doesn't stop the ones after it: every handler runs, then the failure is rethrown (an `AggregateException` when several fail) so the outbox retries the event, and the inbox skips the handlers that already succeeded. Great for dev/test and single-process production hosts. - **`RabbitMqEventBus`** - durable, cross-service; uses `RabbitMQ.Client` and publishes to a durable topic exchange (`EventingOptions:RabbitMQ`). ### Outbox / inbox @@ -51,6 +51,16 @@ Because outbox rows follow the tenant connection, the dispatcher can't poll a si `ClaimBatchAsync` leases rows with Postgres `FOR UPDATE SKIP LOCKED` in one `UPDATE … RETURNING`, so several API instances partition a batch instead of all publishing the same message. The lease is an expiry (`OutboxClaimLeaseSeconds`, default 300), so a dispatcher that dies mid-batch has its rows recovered rather than stranded. Providers without `SKIP LOCKED` fall back to an unclaimed read and log a warning that only one instance is safe. +## Source and consumer routing + +The **source database** holds the Outbox row and receives its processed, retry and dead-letter state. The **consumer database** follows the event's `TenantId`: `InMemoryEventBus` runs its handlers inside `IEventTenantScope.ExecuteAsync`, and the Multitenancy module's `FinbuckleEventTenantScope` installs the full tenant (including a dedicated connection string) before consumers, tenant-aware DbContexts and the Inbox are built. + +- A context that already has the tenant and a connection string is reused. Otherwise the tenant is read from the cached tenant store, then the tenant catalog. +- An unknown or inactive tenant logs a warning and dispatches with an ID-only context. +- A global event (null or empty `TenantId`) leaves the ambient context untouched. + +This describes the InMemory path, not a RabbitMQ receiver. + ## How modules consume Eventing Publishing needs **no module registration at all** - the host calls `AddEventingCore` once. A module that *handles* events registers its handlers: @@ -158,7 +168,7 @@ Replace `IEventingDrainTargetProvider` (and `IEventingDrainScope` if the target - **`OutboxClaimLeaseSeconds` must exceed the worst-case time to publish a batch.** If a batch can outlive its lease, another instance re-claims rows still in flight and publishes them twice. - **A background publisher must set the tenant context before `AddAsync`,** not just before the publish - with per-tenant databases the row otherwise lands in the wrong database and is drained under the wrong target. - **Inbox dedupes per handler, by `(EventId, HandlerName)`.** A redelivered event is skipped only for handlers that already completed it; if you mint two events with the same `Id`, the second is silently dropped for every handler. Always generate a fresh `Guid` per event. -- **Background publishers need the tenant context set.** `InMemoryEventBus` does this for you via `IEventTenantScope` (reading `event.TenantId`) *before* resolving handlers - a `MultiTenantDbContext` captures its tenant at construction, so setting it later is too late. If you build your own bus or dispatch path, preserve this ordering or tenant-filtered handlers NRE. +- **Background publishers need the tenant context set.** `InMemoryEventBus` does this for you via `IEventTenantScope.ExecuteAsync` (reading `event.TenantId`) *before* resolving handlers - a `MultiTenantDbContext` captures its tenant at construction, so setting it later is too late. If you build your own bus or dispatch path, preserve this ordering or tenant-filtered handlers NRE. - **RabbitMQ publishing retries in-process** (`PublishRetryCount` / `PublishRetryDelayMs`), but consumers own their durability via inbox + retry semantics. ## Critical files diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 617e0d48..ecad89d0 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-11-04 +lastUpdated: 2026-11-05 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -11,6 +11,10 @@ seo: Notable changes to the kit, newest first. +## 2026-11-05 + +- **Eventing: InMemory consumers now run against the event tenant's database (fix).** `InMemoryEventBus` runs its handler loop inside the new `IEventTenantScope.ExecuteAsync`, and the Multitenancy module's `FinbuckleEventTenantScope` resolves the full tenant (cache first, then the tenant catalog) before consumers, tenant-aware DbContexts and the Inbox are constructed, so a tenant with a dedicated database no longer has its handlers and Inbox rows land in the shared one. A missing or inactive tenant falls back to an ID-only context with a warning, and global events keep the ambient context. The webhook fan-out keeps a complete context the bus already resolved. `ExecuteAsync` has a default implementation over `Begin`, so custom `IEventTenantScope` implementations keep working. See [#1434](https://github.com/fullstackhero/dotnet-starter-kit/pull/1434). + ## 2026-11-04 - **Tests: the test projects are on xUnit v3 (chore).** Every project under `src/Tests` moves from `xunit` 2.9.3 to `xunit.v3.mtp-off` 4.0.2. The `mtp-off` flavour keeps VSTest, so `dotnet test`, `--filter`, the coverlet collector and `coverage.runsettings` work as before, and scaffolded projects need no `global.json` test-runner setting. Test projects are now executables (`Exe`). `IAsyncLifetime` returns `ValueTask` in v3, so `FshWebApplicationFactory` and `MiddlewareWebApplicationFactory` now override `WebApplicationFactory.DisposeAsync()` instead of hiding it. The analyzer rule `xUnit1051` is off for `src/Tests`, because its code fix would make NSubstitute setups expect the test's cancellation token. v3 also runs tests in a different order, which exposed four hidden dependencies between integration tests. Derived `WithWebHostBuilder` hosts left Hangfire's `JobStorage.Current` and the static `Audit` publisher pointing at their disposed storage and channel, so later enqueues failed and request audits were lost. Provisioning-failure tests left tenants with an unreachable database behind for all-tenant jobs to trip over. An audit poll assumed a new tenant's rows fit on one page. And a rate-limit test shared a partition with the test that exhausts it. All four are fixed in the test harness; production code is unchanged. If you added your own test projects or fixtures, make the same changes. See [Unit tests](/docs/testing/unit-tests/#xunit-v3-specifics), [Fixtures](/docs/testing/fixtures-and-seed-data/) and [#1457](https://github.com/fullstackhero/dotnet-starter-kit/issues/1457). diff --git a/src/content/docs/modules/webhooks.mdx b/src/content/docs/modules/webhooks.mdx index dcf3094e..7b22a869 100644 --- a/src/content/docs/modules/webhooks.mdx +++ b/src/content/docs/modules/webhooks.mdx @@ -1,6 +1,6 @@ --- title: Webhooks module -lastUpdated: 2026-06-11 +lastUpdated: 2026-11-05 description: Tenant-scoped webhook subscriptions, open-generic event fan-out, HMAC-signed delivery with secrets encrypted at rest, permission-gated endpoints, Hangfire dispatch with exponential backoff, and a delivery audit log. sidebar: label: Webhooks @@ -24,7 +24,7 @@ You publish integration events anyway (Chat does, Identity does, Files does). Th - **Signing secret encrypted at rest** - the secret is the HMAC key, so it must stay recoverable (a one-way hash would make signing impossible). It is encrypted with ASP.NET Data Protection (`IWebhookSecretProtector`) on create and decrypted only at sign time, so a database breach never exposes plaintext secrets. - **Permission-gated endpoints** - `Webhooks.View` / `Create` / `Delete` / `Test` (previously authentication-only). - **Open-generic event handler** - one `WebhookFanoutHandler` registered for every `IIntegrationEvent`; no per-event setup needed. When a new module publishes a new event, existing subscriptions automatically receive it. -- **Tenant context restoration** - the fanout handler explicitly restores the tenant context before querying subscriptions, so Finbuckle's global query filter sees the right tenant. (This is the single most important gotcha - see below.) +- **Tenant context restoration** - the fanout handler keeps a complete tenant context the event bus already resolved, otherwise it installs an ID-only one, and restores the previous context afterwards, so Finbuckle's global query filter sees the right tenant. (This is the single most important gotcha - see below.) - **HMAC-SHA256 payload signing** - when `SecretHash` is set, every delivery includes `X-Webhook-Signature: sha256={hex}` so subscribers can verify authenticity. - **Hangfire delivery jobs** with `[AutomaticRetry]` - 5 attempts total, exponential backoff (30 s, 2 min, 10 min, 1 h). - **Transient vs permanent error classification** - 5xx, 408, 429 retried; other 4xx permanent. @@ -76,9 +76,13 @@ public sealed class WebhookFanoutHandler : IIntegrationEventHandler(info); + // Keep a complete context (e.g. a dedicated-database connection) the bus already resolved. + if (prev.TenantInfo?.Id != @event.TenantId || string.IsNullOrWhiteSpace(prev.TenantInfo.ConnectionString)) + { + var info = new AppTenantInfo(@event.TenantId, @event.TenantId); + ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = + new MultiTenantContext(info); + } var eventType = typeof(TEvent).Name; var subscriptions = await _db.Subscriptions @@ -112,7 +116,9 @@ services.AddScoped(typeof(IIntegrationEventHandler<>), typeof(WebhookFanoutHandl The DI container materializes the handler for every event type the eventing system sees. Zero per-event code. -The handler installs the event's tenant into `IMultiTenantContextSetter` before querying subscriptions (and restores the previous context after). Without it, the Finbuckle query filter on `WebhookDbContext` filters away every row because the tenant context is null on the background event-bus thread. **This is the easiest thing to forget when adding a new handler.** +The handler installs the event's tenant into `IMultiTenantContextSetter` before querying subscriptions (and restores the previous context after), unless the bus already resolved a complete one. Without it, the Finbuckle query filter on `WebhookDbContext` filters away every row because the tenant context is null on the background event-bus thread. **This is the easiest thing to forget when adding a new handler.** + +On the InMemory path the event bus resolves the full tenant before the handler is built; see [Eventing routing](/docs/building-blocks/eventing/#source-and-consumer-routing). ## The delivery job