Files
git.stella-ops.org/src/__Libraries/StellaOps.Provcache.Postgres/PostgresProvcacheRepository.cs

346 lines
12 KiB
C#

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;
/// <summary>
/// PostgreSQL implementation of <see cref="IProvcacheRepository"/>.
/// </summary>
public sealed class PostgresProvcacheRepository : IProvcacheRepository
{
private readonly ProvcacheDbContext _context;
private readonly ILogger<PostgresProvcacheRepository> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly JsonSerializerOptions _jsonOptions;
public PostgresProvcacheRepository(
ProvcacheDbContext context,
ILogger<PostgresProvcacheRepository> 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
};
}
/// <inheritdoc />
public async Task<ProvcacheEntry?> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ProvcacheEntry>> GetManyAsync(
IEnumerable<string> veriKeys,
CancellationToken cancellationToken = default)
{
var keyList = veriKeys.ToList();
if (keyList.Count == 0)
return new Dictionary<string, ProvcacheEntry>();
var entities = await _context.ProvcacheItems
.AsNoTracking()
.Where(e => keyList.Contains(e.VeriKey))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.ToDictionary(e => e.VeriKey, MapToEntry);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task UpsertManyAsync(IEnumerable<ProvcacheEntry> 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);
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
public async Task<long> 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;
}
/// <inheritdoc />
public async Task<long> 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;
}
/// <inheritdoc />
public async Task<long> 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;
}
/// <inheritdoc />
public async Task<long> 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;
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task<ProvcacheStatistics> 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<ReplaySeed>(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
};
}
}