using System.Globalization; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using StellaOps.Determinism; using StellaOps.Provcache.Entities; namespace StellaOps.Provcache.Postgres; /// /// PostgreSQL implementation of . /// public sealed class PostgresProvcacheRepository : IProvcacheRepository { private readonly ProvcacheDbContext _context; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; private readonly JsonSerializerOptions _jsonOptions; public PostgresProvcacheRepository( ProvcacheDbContext context, ILogger logger, TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; _guidProvider = guidProvider ?? SystemGuidProvider.Instance; _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; } /// public async Task GetAsync(string veriKey, CancellationToken cancellationToken = default) { var entity = await _context.ProvcacheItems .AsNoTracking() .FirstOrDefaultAsync(e => e.VeriKey == veriKey, cancellationToken) .ConfigureAwait(false); return entity is null ? null : MapToEntry(entity); } /// public async Task> GetManyAsync( IEnumerable veriKeys, CancellationToken cancellationToken = default) { var keyList = veriKeys.ToList(); if (keyList.Count == 0) return new Dictionary(); var entities = await _context.ProvcacheItems .AsNoTracking() .Where(e => keyList.Contains(e.VeriKey)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return entities.ToDictionary(e => e.VeriKey, MapToEntry); } /// public async Task UpsertAsync(ProvcacheEntry entry, CancellationToken cancellationToken = default) { var entity = MapToEntity(entry); var existing = await _context.ProvcacheItems .FirstOrDefaultAsync(e => e.VeriKey == entry.VeriKey, cancellationToken) .ConfigureAwait(false); if (existing is null) { _context.ProvcacheItems.Add(entity); } else { _context.Entry(existing).CurrentValues.SetValues(entity); } await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// public async Task UpsertManyAsync(IEnumerable entries, CancellationToken cancellationToken = default) { var entryList = entries.ToList(); if (entryList.Count == 0) return; var veriKeys = entryList.Select(e => e.VeriKey).ToList(); var existing = await _context.ProvcacheItems .Where(e => veriKeys.Contains(e.VeriKey)) .ToDictionaryAsync(e => e.VeriKey, cancellationToken) .ConfigureAwait(false); foreach (var entry in entryList) { var entity = MapToEntity(entry); if (existing.TryGetValue(entry.VeriKey, out var existingEntity)) { _context.Entry(existingEntity).CurrentValues.SetValues(entity); } else { _context.ProvcacheItems.Add(entity); } } await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// public async Task DeleteAsync(string veriKey, CancellationToken cancellationToken = default) { var entity = await _context.ProvcacheItems .FirstOrDefaultAsync(e => e.VeriKey == veriKey, cancellationToken) .ConfigureAwait(false); if (entity is null) return false; _context.ProvcacheItems.Remove(entity); await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return true; } /// public async Task DeleteByPolicyHashAsync(string policyHash, CancellationToken cancellationToken = default) { var deleted = await _context.ProvcacheItems .Where(e => e.PolicyHash == policyHash) .ExecuteDeleteAsync(cancellationToken) .ConfigureAwait(false); if (deleted > 0) { await LogRevocationAsync("policy", policyHash, "policy-update", deleted, cancellationToken) .ConfigureAwait(false); } return deleted; } /// public async Task DeleteBySignerSetHashAsync(string signerSetHash, CancellationToken cancellationToken = default) { var deleted = await _context.ProvcacheItems .Where(e => e.SignerSetHash == signerSetHash) .ExecuteDeleteAsync(cancellationToken) .ConfigureAwait(false); if (deleted > 0) { await LogRevocationAsync("signer", signerSetHash, "signer-revocation", deleted, cancellationToken) .ConfigureAwait(false); } return deleted; } /// public async Task DeleteByFeedEpochOlderThanAsync(string feedEpoch, CancellationToken cancellationToken = default) { var deleted = await _context.ProvcacheItems .Where(e => string.Compare(e.FeedEpoch, feedEpoch, StringComparison.Ordinal) < 0) .ExecuteDeleteAsync(cancellationToken) .ConfigureAwait(false); if (deleted > 0) { await LogRevocationAsync("feed", feedEpoch, "feed-update", deleted, cancellationToken) .ConfigureAwait(false); } return deleted; } /// public async Task DeleteExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default) { var deleted = await _context.ProvcacheItems .Where(e => e.ExpiresAt <= asOf) .ExecuteDeleteAsync(cancellationToken) .ConfigureAwait(false); if (deleted > 0) { await LogRevocationAsync("expired", asOf.ToString("O", CultureInfo.InvariantCulture), "ttl-expiry", deleted, cancellationToken) .ConfigureAwait(false); } return deleted; } /// public async Task IncrementHitCountAsync(string veriKey, CancellationToken cancellationToken = default) { var now = _timeProvider.GetUtcNow(); await _context.ProvcacheItems .Where(e => e.VeriKey == veriKey) .ExecuteUpdateAsync( setters => setters .SetProperty(e => e.HitCount, e => e.HitCount + 1) .SetProperty(e => e.LastAccessedAt, now), cancellationToken) .ConfigureAwait(false); } /// public async Task GetStatisticsAsync(CancellationToken cancellationToken = default) { var now = _timeProvider.GetUtcNow(); var hourFromNow = now.AddHours(1); var totalEntries = await _context.ProvcacheItems .LongCountAsync(cancellationToken) .ConfigureAwait(false); var totalHits = await _context.ProvcacheItems .SumAsync(e => e.HitCount, cancellationToken) .ConfigureAwait(false); var expiringWithinHour = await _context.ProvcacheItems .LongCountAsync(e => e.ExpiresAt <= hourFromNow, cancellationToken) .ConfigureAwait(false); var uniquePolicies = await _context.ProvcacheItems .Select(e => e.PolicyHash) .Distinct() .CountAsync(cancellationToken) .ConfigureAwait(false); var uniqueSignerSets = await _context.ProvcacheItems .Select(e => e.SignerSetHash) .Distinct() .CountAsync(cancellationToken) .ConfigureAwait(false); var oldest = await _context.ProvcacheItems .OrderBy(e => e.CreatedAt) .Select(e => (DateTimeOffset?)e.CreatedAt) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); var newest = await _context.ProvcacheItems .OrderByDescending(e => e.CreatedAt) .Select(e => (DateTimeOffset?)e.CreatedAt) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); return new ProvcacheStatistics { TotalEntries = totalEntries, TotalHits = totalHits, ExpiringWithinHour = expiringWithinHour, UniquePolicies = uniquePolicies, UniqueSignerSets = uniqueSignerSets, OldestEntry = oldest, NewestEntry = newest }; } private async Task LogRevocationAsync( string type, string targetHash, string reason, long entriesAffected, CancellationToken cancellationToken) { var revocation = new ProvcacheRevocationEntity { RevocationId = _guidProvider.NewGuid(), RevocationType = type, TargetHash = targetHash, Reason = reason, EntriesAffected = entriesAffected, CreatedAt = _timeProvider.GetUtcNow() }; _context.Revocations.Add(revocation); await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Logged revocation: type={Type}, target={TargetHash}, affected={EntriesAffected}", type, targetHash, entriesAffected); } private ProvcacheEntry MapToEntry(ProvcacheItemEntity entity) { var replaySeed = JsonSerializer.Deserialize(entity.ReplaySeed, _jsonOptions) ?? new ReplaySeed { FeedIds = [], RuleIds = [] }; return new ProvcacheEntry { VeriKey = entity.VeriKey, Decision = new DecisionDigest { DigestVersion = entity.DigestVersion, VeriKey = entity.VeriKey, VerdictHash = entity.VerdictHash, ProofRoot = entity.ProofRoot, ReplaySeed = replaySeed, CreatedAt = entity.CreatedAt, ExpiresAt = entity.ExpiresAt, TrustScore = entity.TrustScore }, PolicyHash = entity.PolicyHash, SignerSetHash = entity.SignerSetHash, FeedEpoch = entity.FeedEpoch, CreatedAt = entity.CreatedAt, ExpiresAt = entity.ExpiresAt, HitCount = entity.HitCount, LastAccessedAt = entity.LastAccessedAt }; } private ProvcacheItemEntity MapToEntity(ProvcacheEntry entry) { return new ProvcacheItemEntity { VeriKey = entry.VeriKey, DigestVersion = entry.Decision.DigestVersion, VerdictHash = entry.Decision.VerdictHash, ProofRoot = entry.Decision.ProofRoot, ReplaySeed = JsonSerializer.Serialize(entry.Decision.ReplaySeed, _jsonOptions), PolicyHash = entry.PolicyHash, SignerSetHash = entry.SignerSetHash, FeedEpoch = entry.FeedEpoch, TrustScore = entry.Decision.TrustScore, HitCount = entry.HitCount, CreatedAt = entry.CreatedAt, ExpiresAt = entry.ExpiresAt, UpdatedAt = _timeProvider.GetUtcNow(), LastAccessedAt = entry.LastAccessedAt }; } }