Files

140 lines
5.1 KiB
C#

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<TenantDirectoryEntry?> 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<TenantDirectoryEntry?> 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<TenantDirectoryEntry?> FindAsync(
string sql,
string kind,
string lookup,
CancellationToken cancellationToken)
{
var cacheKey = $"tenant-directory:v1:{kind}:{lookup}";
if (memoryCache.TryGetValue<CacheEnvelope>(cacheKey, out var memoryValue) && memoryValue is not null)
{
return memoryValue.Entry;
}
var distributedCache = serviceProvider.GetService<IDistributedCache>();
if (distributedCache is not null)
{
try
{
var json = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
if (json is not null)
{
var distributedValue = JsonSerializer.Deserialize<CacheEnvelope>(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<TenantStatus>(reader.GetString(3)),
ParseEnum<TenantMode>(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<TEnum>(string value)
where TEnum : struct, Enum
{
return Enum.Parse<TEnum>(value.Replace("_", string.Empty, StringComparison.Ordinal), true);
}
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
}