File size: 4,929 Bytes
af8cc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using OptimAI.BRE.RuleEngine.Domain;
using OptimAI.BRE.Shared.Domain;
using System.Text.Json;

namespace OptimAI.BRE.RuleEngine.Infrastructure;

/// <summary>
/// Redis-backed rule loader with 5-minute cache TTL.
/// Cache key includes tenant + product + branch + stage for scope-aware loading.
/// </summary>
public sealed class CachedRuleLoader : IRuleLoader
{
    private readonly BREDbContext _db;
    private readonly IDistributedCache _cache;
    private readonly ILogger<CachedRuleLoader> _logger;

    private static readonly JsonSerializerOptions _jsonOpts = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public CachedRuleLoader(BREDbContext db, IDistributedCache cache, ILogger<CachedRuleLoader> logger)
    {
        _db = db;
        _cache = cache;
        _logger = logger;
    }

    public async Task<IReadOnlyList<Rule>> LoadRulesAsync(RuleLoadRequest request, CancellationToken ct = default)
    {
        var cacheKey = BuildCacheKey(request);

        var cached = await _cache.GetStringAsync(cacheKey, ct);
        if (cached != null)
        {
            _logger.LogDebug("Cache HIT for rules: {Key}", cacheKey);
            return JsonSerializer.Deserialize<List<Rule>>(cached, _jsonOpts) ?? new();
        }

        _logger.LogDebug("Cache MISS for rules: {Key}", cacheKey);
        var rules = await LoadFromDatabaseAsync(request, ct);

        await _cache.SetStringAsync(cacheKey,
            JsonSerializer.Serialize(rules, _jsonOpts),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            }, ct);

        return rules;
    }

    public async Task<Rule?> LoadRuleByCodeAsync(Guid tenantId, string ruleCode, CancellationToken ct = default)
    {
        return await _db.Rules
            .Include(r => r.CurrentVersion)
            .Include(r => r.Scopes)
            .FirstOrDefaultAsync(r => r.TenantId == tenantId && r.RuleCode == ruleCode, ct);
    }

    private async Task<List<Rule>> LoadFromDatabaseAsync(RuleLoadRequest request, CancellationToken ct)
    {
        var query = _db.Rules
            .Include(r => r.CurrentVersion)
            .Include(r => r.Scopes)
            .Where(r => r.TenantId == request.TenantId);

        if (request.PublishedOnly)
            query = query.Where(r => r.IsPublished && r.Status == RuleStatus.Published);

        if (request.RuleTypes.Any())
            query = query.Where(r => request.RuleTypes.Contains(r.RuleType));

        if (request.RuleSetId.HasValue)
        {
            var ruleIds = await _db.RuleSetMembers
                .Where(m => m.SetId == request.RuleSetId.Value)
                .Select(m => m.RuleId)
                .ToListAsync(ct);

            query = query.Where(r => ruleIds.Contains(r.Id));
        }
        else
        {
            // Scope filtering: include GLOBAL rules + rules scoped to the request context
            query = query.Where(r =>
                !r.Scopes.Any() ||  // no scope = global
                r.Scopes.Any(s => s.ScopeType == ScopeType.Global) ||
                (request.ProductCode != null && r.Scopes.Any(s =>
                    s.ScopeType == ScopeType.Product && s.ScopeValue == request.ProductCode && !s.IsExcluded)) ||
                (request.BranchCode != null && r.Scopes.Any(s =>
                    s.ScopeType == ScopeType.Branch && s.ScopeValue == request.BranchCode && !s.IsExcluded)) ||
                (request.StageCode != null && r.Scopes.Any(s =>
                    s.ScopeType == ScopeType.Stage && s.ScopeValue == request.StageCode && !s.IsExcluded))
            );
        }

        return await query
            .OrderBy(r => r.Priority)
            .AsNoTracking()
            .ToListAsync(ct);
    }

    public async Task InvalidateCacheAsync(Guid tenantId, CancellationToken ct = default)
    {
        // Invalidate all rule cache entries for this tenant
        // In production, use a tag-based cache invalidation strategy
        var pattern = $"bre:rules:{tenantId}:*";
        _logger.LogInformation("Invalidating rule cache for tenant {TenantId}", tenantId);
        // Redis SCAN + DEL for pattern — implement via IConnectionMultiplexer if needed
    }

    private static string BuildCacheKey(RuleLoadRequest request)
    {
        var parts = new[]
        {
            "bre", "rules",
            request.TenantId.ToString(),
            request.ProductCode ?? "ALL",
            request.BranchCode ?? "ALL",
            request.StageCode ?? "ALL",
            request.RuleSetId?.ToString() ?? "ALL",
            string.Join(",", request.RuleTypes.OrderBy(t => t.ToString())),
            request.PublishedOnly ? "pub" : "all"
        };
        return string.Join(":", parts);
    }
}