Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,18 @@ public interface IEventTenantScope
/// ambient context unchanged (global, non-tenant-scoped events).
/// </summary>
IDisposable Begin(string? tenantId);

/// <summary>
/// Runs <paramref name="action"/> inside the tenant scope for <paramref name="tenantId"/>. Implementations
/// that resolve tenant metadata asynchronously override this so resolution and the action share one
/// async flow. The default bridges to <see cref="Begin"/>.
/// </summary>
async Task ExecuteAsync(string? tenantId, Func<CancellationToken, Task> action, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(action);
using (Begin(tenantId))
{
await action(ct).ConfigureAwait(false);
}
}
}
4 changes: 2 additions & 2 deletions src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke

// Set tenant context BEFORE resolving handlers — MultiTenantDbContext captures TenantInfo at
// construction, so a late tenant NREs the query filter. This is what makes background publishers work.
using (_tenantScope.Begin(@event.TenantId))
await _tenantScope.ExecuteAsync(@event.TenantId, async _ =>
{
// One failing handler must not starve the others: every handler gets its turn, and the failure
// surfaces afterwards so the outbox retries the event. The inbox records each handler that
Expand Down Expand Up @@ -105,7 +105,7 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke
}

ThrowIfAnyFailed(failures, @event.Id);
}
}, ct).ConfigureAwait(false);
}

private static void ThrowIfAnyFailed(List<ExceptionDispatchInfo>? failures, Guid eventId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using Finbuckle.MultiTenant.EntityFrameworkCore.Stores;
using Finbuckle.MultiTenant.Stores;
using FSH.Framework.Eventing.Abstractions;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Multitenancy.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace FSH.Modules.Multitenancy.Services;

Expand All @@ -11,22 +16,26 @@ namespace FSH.Modules.Multitenancy.Services;
/// handler DbContexts resolved afterwards capture a real <c>TenantInfo</c> instead of the
/// null one a background scope would otherwise carry.
///
/// Mirrors the create-scope-then-set-tenant pattern already used by
/// <c>WebhookDispatchJob</c> / <c>SqlAuditSink</c>, generalized to the event pipeline.
/// Only tenant identity is set (sufficient for the row-level tenant query filter in the
/// shared-database model); per-tenant connection strings are not resolved here.
/// <see cref="ExecuteAsync"/> resolves the full tenant (including a dedicated connection string)
/// before the action constructs consumers, in the same async flow.
/// </summary>
public sealed class FinbuckleEventTenantScope : IEventTenantScope
{
private readonly IMultiTenantContextAccessor<AppTenantInfo> _accessor;
private readonly IMultiTenantContextSetter _setter;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FinbuckleEventTenantScope> _logger;

public FinbuckleEventTenantScope(
IMultiTenantContextAccessor<AppTenantInfo> accessor,
IMultiTenantContextSetter setter)
IMultiTenantContextSetter setter,
IServiceScopeFactory scopeFactory,
ILogger<FinbuckleEventTenantScope> logger)
{
_accessor = accessor;
_setter = setter;
_scopeFactory = scopeFactory;
_logger = logger;
}

public IDisposable Begin(string? tenantId)
Expand All @@ -44,6 +53,63 @@ public IDisposable Begin(string? tenantId)
return new RestoreScope(_setter, previous);
}

public async Task ExecuteAsync(
string? tenantId,
Func<CancellationToken, Task> action,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(action);

var previous = _accessor.MultiTenantContext;
if (string.IsNullOrWhiteSpace(tenantId) ||
(string.Equals(previous.TenantInfo?.Id, tenantId, StringComparison.Ordinal) &&
!string.IsNullOrWhiteSpace(previous.TenantInfo?.ConnectionString)))
{
await action(ct).ConfigureAwait(false);
return;
}

try
{
var tenant = await FindTenantAsync(tenantId, ct).ConfigureAwait(false);
if (tenant is null || !tenant.IsActive)
{
_logger.LogWarning("Event tenant {TenantId} is missing or inactive; dispatching with tenant identity only", tenantId);
tenant = new AppTenantInfo(tenantId, tenantId);
}

_setter.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);
await action(ct).ConfigureAwait(false);
}
finally
{
_setter.MultiTenantContext = previous;
}
}

private async Task<AppTenantInfo?> FindTenantAsync(string tenantId, CancellationToken ct)
{
// A single IMultiTenantStore resolve returns only the last registration (the EF store), so enumerate all of them.
await using var scope = _scopeFactory.CreateAsyncScope();
var stores = scope.ServiceProvider.GetServices<IMultiTenantStore<AppTenantInfo>>().ToList();
var cache = stores.OfType<DistributedCacheStore<AppTenantInfo>>().Single();

var tenant = await cache.GetAsync(tenantId).WaitAsync(ct).ConfigureAwait(false);
if (tenant is not null)
{
return tenant;
}

var efStore = stores.OfType<EFCoreStore<TenantDbContext, AppTenantInfo>>().Single();
tenant = await efStore.GetAsync(tenantId).WaitAsync(ct).ConfigureAwait(false);
if (tenant is not null)
{
await cache.AddAsync(tenant).ConfigureAwait(false);
}

return tenant;
}

private sealed class RestoreScope : IDisposable
{
private readonly IMultiTenantContextSetter _setter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ public async Task HandleAsync(TEvent @event, CancellationToken ct = default)
var prev = _tenantContextAccessor.MultiTenantContext;
try
{
var info = new AppTenantInfo(@event.TenantId, @event.TenantId);
((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext =
new MultiTenantContext<AppTenantInfo>(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<AppTenantInfo>(info);
}

var eventType = typeof(TEvent).Name;

Expand Down
Loading