Перейти к основному содержимому

Caching, Scrutor Decorators, Redis — Практическое Руководство

Назначение

Документ описывает, как правильно использовать Redis caching, регистрировать decorators через Scrutor, организовывать logging/tracing decorators для ports и adapters. Является практическим дополнением к Caching, Decorators And Resilience.


Где Кэшировать

Разрешено

Что КэшироватьTTLИнвалидация
Lookup-справочники handbook (валюты, страны)1 часПо событию pin.handbook.*-upserted (ADR-0063)
Catalog public projections5 минПо integration event от catalog service
Tenant/brand settings snapshots2 минПо integration event от platform config
Payment provider availability30 секПо status event / health check
User dashboard summary15 секПо accounting ledger event
Partner dashboard aggregates1 минС freshness marker в response
Elasticsearch/ClickHouse report metadata30 секПо freshness SLA
Idempotency short replay cacheTTL операцииПоверх DB unique constraints

Запрещено

  • Ledger balance как единственный источник истины.
  • KYC/AML/User Safety decisions для high-impact command без owner check.
  • Provider credentials, tokens, raw PII.
  • Unbounded report result без row limit и TTL.
  • Кэш без scope (TenantId/BrandId/ActorId) для user/partner data.

Cache Key Convention

Pin:{env}:{service}:{capability}:tenant:{tenantId}:brand:{brandId}:{key}

Примеры:

Pin:prod:accounting:account-summary:tenant:018f...:brand:018f...:user:018f...
Pin:prod:catalog:game-page:tenant:018f...:brand:018f...:slugs:slots:p1
Pin:prod:reference:currencies:global (нет tenant scope)
Pin:prod:platform:settings:tenant:018f...:brand:018f...

Правила:

  • User/partner-owned cache всегда включает UserId или PartnerId.
  • Global/reference data cache не включает actor scope.
  • Cache key не содержит raw PII, email, phone, IP.
  • Среда (prod/staging/dev) предотвращает пересечение данных.

ICacheableQuery В MediatR

Query объявляет себя кешируемым через ICacheableQuery<TResult>:

// Pin.Accounting.Application/Accounts/GetAccountingSummary/GetAccountingSummaryQuery.cs

public sealed record GetAccountingSummaryQuery(AccountingAccountId AccountingId)
: IQuery<AccountingSummaryView>, ICacheableQuery<AccountingSummaryView>
{
// Инжектируем providers через constructor для key формирования
// НО! CacheKey должен быть pure и зависеть только от request data + scope
// Scope vars берутся из CacheKey при регистрации behavior

public string CacheKey => $"accounting:account-summary:{AccountingId}";
public TimeSpan Ttl => TimeSpan.FromSeconds(30);
public string? InvalidationTag => $"accounting:account:{AccountingId}";
public CachePrivacyLevel PrivacyLevel => CachePrivacyLevel.UserOwned;
}

QueryCacheBehavior строит full cache key добавляя tenant/brand/actor scope из providers:

// В behavior, не в query
private string BuildFullCacheKey(ICacheableQuery<TResult> query)
{
var tenantId = _tenantProvider.TryGet()?.ToString() ?? "global";
var brandId = _brandProvider.TryGet()?.ToString() ?? "any";
var actorScope = query.PrivacyLevel switch
{
CachePrivacyLevel.UserOwned => $":user:{_userProvider.TryGet()}",
CachePrivacyLevel.PartnerOwned => $":partner:{_partnerProvider.TryGet()}",
_ => ""
};
return $"Pin:{_env}:{_service}:{query.CacheKey}:tenant:{tenantId}:brand:{brandId}{actorScope}";
}

Scrutor Decorator Pattern

Scrutor позволяет регистрировать decorators через DI без изменения реализации:

// Talent.Common.Observability/DependencyInjection/

// Без Scrutor (боilerplate)
services.AddTransient<IPaymentProviderAdapter, StripeAdapter>();
services.AddTransient<IPaymentProviderAdapter>(sp =>
new LoggingDecorator<IPaymentProviderAdapter>(
new TracingDecorator<IPaymentProviderAdapter>(
sp.GetRequiredService<StripeAdapter>())));

// С Scrutor (чисто)
services.AddTransient<IPaymentProviderAdapter, StripeAdapter>();
services.Decorate<IPaymentProviderAdapter, LoggingDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, TracingDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, MetricsDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, RetryDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, CircuitBreakerDecorator<IPaymentProviderAdapter>>();

Extension Method Для Стандартного Набора

// Talent.Common.Observability

public static class ServiceCollectionExtensions
{
// Добавляет logging + tracing + metrics + retry decorators для port/adapter
public static IServiceCollection DecorateWithPinDefaults<TInterface>(
this IServiceCollection services)
where TInterface : class
{
services.Decorate<TInterface, LoggingDecorator<TInterface>>();
services.Decorate<TInterface, TracingDecorator<TInterface>>();
services.Decorate<TInterface, MetricsDecorator<TInterface>>();
return services;
}

// Добавляет cache decorator для read ports
public static IServiceCollection DecorateWithCache<TInterface>(
this IServiceCollection services,
Action<CacheDecoratorOptions> configure)
where TInterface : class
{
services.Configure(configure);
services.Decorate<TInterface, CacheDecorator<TInterface>>();
return services;
}

// Добавляет resilience decorators для external provider adapters
public static IServiceCollection DecorateWithResilience<TInterface>(
this IServiceCollection services,
string policyName)
where TInterface : class
{
services.Decorate<TInterface>(
(inner, sp) => new RetryDecorator<TInterface>(inner,
sp.GetRequiredService<IResiliencePolicyProvider>().GetRetry(policyName)));
services.Decorate<TInterface>(
(inner, sp) => new CircuitBreakerDecorator<TInterface>(inner,
sp.GetRequiredService<IResiliencePolicyProvider>().GetCircuitBreaker(policyName)));
return services;
}
}

Logging Decorator

Generic logging decorator для любого port/adapter:

// Talent.Common.Observability/Decorators/LoggingDecorator.cs

public sealed class LoggingDecorator<TInterface> : DispatchProxy
where TInterface : class
{
private TInterface _inner = default!;
private ILogger _logger = default!;
private ICorrelationContext _correlationContext = default!;

protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
var methodName = targetMethod?.Name ?? "Unknown";
var correlationId = _correlationContext.CorrelationId;

_logger.LogDebug(
"Calling {Interface}.{Method} {CorrelationId}",
typeof(TInterface).Name, methodName, correlationId);

try
{
var result = targetMethod!.Invoke(_inner, args);
if (result is Task task)
return HandleAsync(task, methodName, correlationId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error in {Interface}.{Method} {CorrelationId}",
typeof(TInterface).Name, methodName, correlationId);
throw;
}
}

// Для async методов
private async Task HandleAsync(Task task, string methodName, CorrelationId correlationId)
{
var sw = Stopwatch.StartNew();
try
{
await task;
_logger.LogDebug(
"Completed {Interface}.{Method} in {Ms}ms {CorrelationId}",
typeof(TInterface).Name, methodName, sw.ElapsedMilliseconds, correlationId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed {Interface}.{Method} in {Ms}ms {CorrelationId}",
typeof(TInterface).Name, methodName, sw.ElapsedMilliseconds, correlationId);
throw;
}
}

// Factory метод
public static TInterface Create(
TInterface inner, ILogger logger, ICorrelationContext correlationContext)
{
var proxy = (LoggingDecorator<TInterface>)Create<TInterface, LoggingDecorator<TInterface>>();
proxy._inner = inner;
proxy._logger = logger;
proxy._correlationContext = correlationContext;
return (TInterface)(object)proxy;
}
}

Упрощённый Вариант Через Класс-Обёртку

Для конкретных interfaces предпочтительнее именованная обёртка (не DispatchProxy):

// Pin.Accounting.Infrastructure/Adapters/Stripe/

internal sealed class LoggingStripeAdapter : IStripePaymentAdapter
{
private readonly IStripePaymentAdapter _inner;
private readonly ILogger<LoggingStripeAdapter> _logger;
private readonly ICorrelationContext _correlation;

public async Task<StripeChargeResult> CreateCharge(
StripeChargeRequest request, CancellationToken ct)
{
_logger.LogInformation(
"Stripe CreateCharge amount={Amount} currency={Currency} correlationId={CorrelationId}",
request.Amount, request.Currency, _correlation.CorrelationId);

var sw = Stopwatch.StartNew();
try
{
var result = await _inner.CreateCharge(request, ct);
_logger.LogInformation(
"Stripe CreateCharge completed chargeId={ChargeId} in {Ms}ms",
result.ChargeId, sw.ElapsedMilliseconds);
return result;
}
catch (StripeException ex)
{
_logger.LogWarning(
"Stripe CreateCharge failed code={Code} in {Ms}ms correlationId={CorrelationId}",
ex.StripeError?.Code, sw.ElapsedMilliseconds, _correlation.CorrelationId);
throw;
}
}
}

Tracing Decorator

// Talent.Common.Observability/Decorators/TracingDecorator.cs

public sealed class TracingStripeAdapter : IStripePaymentAdapter
{
private readonly IStripePaymentAdapter _inner;
private static readonly ActivitySource _source = PinActivitySource.Instance;

public async Task<StripeChargeResult> CreateCharge(
StripeChargeRequest request, CancellationToken ct)
{
using var activity = _source.StartActivity(
"stripe.create_charge",
ActivityKind.Client);

// Безопасные tags — без PII/secrets
activity?.SetTag("payment.provider", "stripe");
activity?.SetTag("payment.currency", request.Currency);
// НЕ добавляем: card number, CVV, provider secret key

try
{
var result = await _inner.CreateCharge(request, ct);
activity?.SetTag("payment.charge_id_hash",
HashHelper.StableHash(result.ChargeId)); // hash, не raw id
activity?.SetStatus(ActivityStatusCode.Ok);
return result;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}

Cache Decorator для Read Ports

// Talent.Common.Observability/Decorators/CacheDecorator.cs

public sealed class CachingGameCatalogPort : IGameCatalogPort
{
private readonly IGameCatalogPort _inner;
private readonly IDistributedCacheProvider _cache;
private readonly ITenantScopeProvider _tenantProvider;
private readonly IBrandScopeProvider _brandProvider;

public async Task<GamePageResult> GetGamePage(
GamePageQuery query, CancellationToken ct)
{
var tenantId = _tenantProvider.GetRequired();
var brandId = _brandProvider.TryGet();
var cacheKey = $"Pin:prod:catalog:game-page:tenant:{tenantId}:brand:{brandId}:{query.Slug}:p{query.Page}";

var cached = await _cache.Get<GamePageResult>(cacheKey, ct);
if (cached is not null)
{
_logger.LogDebug("Cache hit for game page {Key}", cacheKey);
return cached with { Freshness = FreshnessView.FromCache() };
}

var result = await _inner.GetGamePage(query, ct);
await _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5), ct);
return result;
}
}

Redis Provider

Инициализация Redis

// Talent.Common.Infrastructure/DependencyInjection/AddRedis.cs

public static IPinInfrastructureBuilder AddPinRedis(
this IPinInfrastructureBuilder builder,
Action<RedisOptions>? configure = null)
{
builder.Services
.AddOptions<RedisOptions>()
.BindConfiguration("Redis")
.ValidateDataAnnotations()
.ValidateOnStart();

if (configure is not null)
builder.Services.Configure(configure);

builder.Services.AddStackExchangeRedisCache(opts =>
{
opts.Configuration = builder.Services
.BuildServiceProvider()
.GetRequiredService<IOptions<RedisOptions>>().Value.ConnectionString;
});

builder.Services.AddSingleton<IDistributedCacheProvider, RedisDistributedCacheProvider>();
builder.Services.AddSingleton<IRedisLockProvider, RedisLockProvider>();
builder.Services.AddSingleton<INonceReplayProtectionStore, RedisNonceStore>();
builder.Services.AddSingleton<IShortLivedIdempotencyCache, RedisIdempotencyCache>();

return builder;
}

Redis Lock

// Talent.Common.Infrastructure/Redis/RedisLockProvider.cs

public sealed class RedisLockProvider : IRedisLockProvider
{
// Distributed lock с owner token, TTL и safe release
public async Task<IAsyncDisposable?> TryAcquireAsync(
string lockKey, TimeSpan ttl, CancellationToken ct)
{
var ownerToken = Guid.NewGuid().ToString("N");
var fullKey = $"Pin:lock:{_service}:{lockKey}";

var acquired = await _database.StringSetAsync(
fullKey, ownerToken, ttl, When.NotExists);

if (!acquired)
{
_logger.LogDebug("Lock {Key} not acquired", fullKey);
return null;
}

return new LockHandle(
_database, fullKey, ownerToken, ttl, _logger);
}
}

private sealed class LockHandle : IAsyncDisposable
{
// Safe release — проверяет owner token перед удалением
public async ValueTask DisposeAsync()
{
var script = LuaScript.Prepare(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end");
await _database.ScriptEvaluateAsync(script,
new { KEYS = new RedisKey[] { _key }, ARGV = new RedisValue[] { _token } });
}
}

Cache Invalidation

Инвалидация через domain/integration events:

// Consumer в accounting service
public sealed class AccountingBalanceChangedConsumer
: IConsumer<AccountingLedgerTransactionPostedEvent>
{
public async Task Consume(ConsumeContext<AccountingLedgerTransactionPostedEvent> ctx)
{
var accountingId = ctx.Message.Payload.AccountId;
var tenantId = ctx.Message.TenantId;
var userId = ctx.Message.Payload.UserId;

// Инвалидируем cache по tag
await _cache.RemoveByTag($"accounting:account:{accountingId}", ct);
await _cache.RemoveByTag($"accounting:user-summary:{userId}", ct);
}
}

Rate Limiting Через Redis

// Talent.Common.Infrastructure/Redis/

public sealed class RedisRateLimitCounterStore : IRateLimitCounterStore
{
public async Task<RateLimitResult> IncrementAsync(
string key, int limit, TimeSpan window, CancellationToken ct)
{
var fullKey = $"Pin:ratelimit:{_service}:{key}";

var script = LuaScript.Prepare(@"
local count = redis.call('incr', KEYS[1])
if count == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
return count");

var count = (long)await _database.ScriptEvaluateAsync(script,
new { KEYS = new RedisKey[] { fullKey }, ARGV = new RedisValue[] { (int)window.TotalSeconds } });

return new RateLimitResult
{
Count = (int)count,
Limit = limit,
IsExceeded = count > limit,
RetryAfter = count > limit ? window : null
};
}
}

Нельзя Делать С Redis

  • Хранить Redis как единственный source of truth для balance, KYC, AML decisions.
  • Очищать весь namespace без incident/runbook reason.
  • Хранить secrets, raw provider credentials, refresh tokens.
  • Использовать Redis как message broker вместо Kafka/MassTransit.
  • Хранить unbounded collections без TTL и size limit.
  • Кэшировать user/partner data без tenant/actor scope в key.

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.