using Npgsql; using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Tenancy; public sealed class TenantDirectory( NpgsqlDataSource dataSource, IMemoryCache memoryCache, IServiceProvider serviceProvider) : ITenantDirectory { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry); public Task FindByHostAsync( string host, CancellationToken cancellationToken = default) { const string sql = """ select t.id, t.slug, t.name, t.status, t.mode, d.host from tenant_domains d join tenants t on t.id = d.tenant_id where d.host = @lookup and d.status = 'active' and t.status = 'active' limit 1 """; return FindAsync(sql, "host", Normalize(host), cancellationToken); } public Task FindByCodeAsync( string tenantCode, CancellationToken cancellationToken = default) { const string sql = """ select t.id, t.slug, t.name, t.status, t.mode, null::text as host from tenants t where t.slug = @lookup and t.status = 'active' limit 1 """; return FindAsync(sql, "code", Normalize(tenantCode), cancellationToken); } private async Task FindAsync( string sql, string kind, string lookup, CancellationToken cancellationToken) { var cacheKey = $"tenant-directory:v1:{kind}:{lookup}"; if (memoryCache.TryGetValue(cacheKey, out var memoryValue) && memoryValue is not null) { return memoryValue.Entry; } var distributedCache = serviceProvider.GetService(); if (distributedCache is not null) { try { var json = await distributedCache.GetStringAsync(cacheKey, cancellationToken); if (json is not null) { var distributedValue = JsonSerializer.Deserialize(json, SerializerOptions); if (distributedValue is not null) { memoryCache.Set(cacheKey, distributedValue, distributedValue.Found ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(20)); return distributedValue.Entry; } } } catch (Exception exception) when (exception is not OperationCanceledException) { // Tenant resolution must fall back to PostgreSQL when Redis is unavailable. } } await using var command = dataSource.CreateCommand(sql); command.Parameters.AddWithValue("lookup", lookup); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(false, null), TimeSpan.FromSeconds(20), cancellationToken); return null; } var result = new TenantDirectoryEntry( reader.GetGuid(0), reader.GetString(1), reader.GetString(2), ParseEnum(reader.GetString(3)), ParseEnum(reader.GetString(4)), reader.IsDBNull(5) ? null : reader.GetString(5)); await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(true, result), TimeSpan.FromSeconds(300), cancellationToken); return result; } private async Task StoreAsync( IDistributedCache? distributedCache, string cacheKey, CacheEnvelope value, TimeSpan distributedDuration, CancellationToken cancellationToken) { memoryCache.Set(cacheKey, value, value.Found ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(20)); if (distributedCache is null) { return; } try { await distributedCache.SetStringAsync( cacheKey, JsonSerializer.Serialize(value, SerializerOptions), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = distributedDuration }, cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { // L1 remains usable and the next miss will fall back to PostgreSQL. } } private static TEnum ParseEnum(string value) where TEnum : struct, Enum { return Enum.Parse(value.Replace("_", string.Empty, StringComparison.Ordinal), true); } private static string Normalize(string value) => value.Trim().ToLowerInvariant(); }