346 lines
12 KiB
C#
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
|
|
};
|
|
}
|
|
}
|