sprints work
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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 JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresProvcacheRepository(
|
||||
ProvcacheDbContext context,
|
||||
ILogger<PostgresProvcacheRepository> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_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"), "ttl-expiry", deleted, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task IncrementHitCountAsync(string veriKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.ProvcacheItems
|
||||
.Where(e => e.VeriKey == veriKey)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(e => e.HitCount, e => e.HitCount + 1)
|
||||
.SetProperty(e => e.LastAccessedAt, DateTimeOffset.UtcNow),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProvcacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
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 = Guid.NewGuid(),
|
||||
RevocationType = type,
|
||||
TargetHash = targetHash,
|
||||
Reason = reason,
|
||||
EntriesAffected = entriesAffected,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_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 = DateTimeOffset.UtcNow,
|
||||
LastAccessedAt = entry.LastAccessedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Provcache.Entities;
|
||||
|
||||
namespace StellaOps.Provcache.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Provcache storage.
|
||||
/// </summary>
|
||||
public class ProvcacheDbContext : DbContext
|
||||
{
|
||||
public ProvcacheDbContext(DbContextOptions<ProvcacheDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<ProvcacheItemEntity> ProvcacheItems => Set<ProvcacheItemEntity>();
|
||||
public DbSet<ProvcacheEvidenceChunkEntity> EvidenceChunks => Set<ProvcacheEvidenceChunkEntity>();
|
||||
public DbSet<ProvcacheRevocationEntity> Revocations => Set<ProvcacheRevocationEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasDefaultSchema("provcache");
|
||||
|
||||
// ProvcacheItemEntity configuration
|
||||
modelBuilder.Entity<ProvcacheItemEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.VeriKey);
|
||||
|
||||
entity.HasIndex(e => e.PolicyHash);
|
||||
entity.HasIndex(e => e.SignerSetHash);
|
||||
entity.HasIndex(e => e.FeedEpoch);
|
||||
entity.HasIndex(e => e.ExpiresAt);
|
||||
entity.HasIndex(e => e.CreatedAt);
|
||||
|
||||
entity.Property(e => e.ReplaySeed)
|
||||
.HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
// ProvcacheEvidenceChunkEntity configuration
|
||||
modelBuilder.Entity<ProvcacheEvidenceChunkEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ChunkId);
|
||||
|
||||
entity.HasIndex(e => e.ProofRoot);
|
||||
entity.HasIndex(e => new { e.ProofRoot, e.ChunkIndex }).IsUnique();
|
||||
});
|
||||
|
||||
// ProvcacheRevocationEntity configuration
|
||||
modelBuilder.Entity<ProvcacheRevocationEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.RevocationId);
|
||||
|
||||
entity.HasIndex(e => e.CreatedAt);
|
||||
entity.HasIndex(e => e.TargetHash);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Provcache.Postgres</RootNamespace>
|
||||
<AssemblyName>StellaOps.Provcache.Postgres</AssemblyName>
|
||||
<Description>PostgreSQL storage implementation for StellaOps Provcache</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user