save progress
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -94,6 +94,16 @@ public sealed record AuditBundleManifest
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay format version for compatibility checks.
|
||||
/// </summary>
|
||||
public string? ReplayVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle version used during evaluation.
|
||||
/// </summary>
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AuditPackExportService.cs
|
||||
// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export
|
||||
// Task: T5 — Backend export service for audit packs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting audit packs in various formats.
|
||||
/// Supports ZIP bundle, JSON, and DSSE envelope formats.
|
||||
/// </summary>
|
||||
public sealed class AuditPackExportService : IAuditPackExportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IAuditBundleWriter _bundleWriter;
|
||||
private readonly IAuditPackRepository? _repository;
|
||||
|
||||
public AuditPackExportService(
|
||||
IAuditBundleWriter bundleWriter,
|
||||
IAuditPackRepository? repository = null)
|
||||
{
|
||||
_bundleWriter = bundleWriter;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports an audit pack based on the provided configuration.
|
||||
/// </summary>
|
||||
public async Task<ExportResult> ExportAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request.Format switch
|
||||
{
|
||||
ExportFormat.Zip => await ExportAsZipAsync(request, cancellationToken),
|
||||
ExportFormat.Json => await ExportAsJsonAsync(request, cancellationToken),
|
||||
ExportFormat.Dsse => await ExportAsDsseAsync(request, cancellationToken),
|
||||
_ => ExportResult.Failed($"Unsupported export format: {request.Format}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports as a ZIP bundle containing all evidence segments.
|
||||
/// </summary>
|
||||
private async Task<ExportResult> ExportAsZipAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
// Create manifest
|
||||
var manifest = CreateManifest(request);
|
||||
await AddJsonToZipAsync(archive, "manifest.json", manifest, ct);
|
||||
|
||||
// Add selected segments
|
||||
foreach (var segment in request.Segments)
|
||||
{
|
||||
var segmentData = await GetSegmentDataAsync(request.ScanId, segment, ct);
|
||||
if (segmentData is not null)
|
||||
{
|
||||
var path = GetSegmentPath(segment);
|
||||
await AddBytesToZipAsync(archive, path, segmentData);
|
||||
}
|
||||
}
|
||||
|
||||
// Add attestations if requested
|
||||
if (request.IncludeAttestations)
|
||||
{
|
||||
var attestations = await GetAttestationsAsync(request.ScanId, ct);
|
||||
if (attestations.Count > 0)
|
||||
{
|
||||
await AddJsonToZipAsync(archive, "attestations/attestations.json", attestations, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Add proof chain if requested
|
||||
if (request.IncludeProofChain)
|
||||
{
|
||||
var proofChain = await GetProofChainAsync(request.ScanId, ct);
|
||||
if (proofChain is not null)
|
||||
{
|
||||
await AddJsonToZipAsync(archive, "proof/proof-chain.json", proofChain, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
var bytes = memoryStream.ToArray();
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
Success = true,
|
||||
Data = bytes,
|
||||
ContentType = "application/zip",
|
||||
Filename = $"{request.Filename}.zip",
|
||||
SizeBytes = bytes.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports as a single JSON document.
|
||||
/// </summary>
|
||||
private async Task<ExportResult> ExportAsJsonAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var exportDoc = new Dictionary<string, object>
|
||||
{
|
||||
["exportedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["scanId"] = request.ScanId,
|
||||
["format"] = "json",
|
||||
["version"] = "1.0"
|
||||
};
|
||||
|
||||
// Add segments
|
||||
var segments = new Dictionary<string, object>();
|
||||
foreach (var segment in request.Segments)
|
||||
{
|
||||
var segmentData = await GetSegmentDataAsync(request.ScanId, segment, ct);
|
||||
if (segmentData is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsedDoc = JsonDocument.Parse(segmentData);
|
||||
segments[segment.ToString().ToLowerInvariant()] = parsedDoc.RootElement;
|
||||
}
|
||||
catch
|
||||
{
|
||||
segments[segment.ToString().ToLowerInvariant()] = Convert.ToBase64String(segmentData);
|
||||
}
|
||||
}
|
||||
}
|
||||
exportDoc["segments"] = segments;
|
||||
|
||||
// Add attestations
|
||||
if (request.IncludeAttestations)
|
||||
{
|
||||
var attestations = await GetAttestationsAsync(request.ScanId, ct);
|
||||
exportDoc["attestations"] = attestations;
|
||||
}
|
||||
|
||||
// Add proof chain
|
||||
if (request.IncludeProofChain)
|
||||
{
|
||||
var proofChain = await GetProofChainAsync(request.ScanId, ct);
|
||||
if (proofChain is not null)
|
||||
{
|
||||
exportDoc["proofChain"] = proofChain;
|
||||
}
|
||||
}
|
||||
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(exportDoc, JsonOptions);
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
Success = true,
|
||||
Data = json,
|
||||
ContentType = "application/json",
|
||||
Filename = $"{request.Filename}.json",
|
||||
SizeBytes = json.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports as a DSSE envelope with signature.
|
||||
/// </summary>
|
||||
private async Task<ExportResult> ExportAsDsseAsync(
|
||||
ExportRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// First create the JSON payload
|
||||
var jsonResult = await ExportAsJsonAsync(request, ct);
|
||||
if (!jsonResult.Success)
|
||||
{
|
||||
return jsonResult;
|
||||
}
|
||||
|
||||
// Create DSSE envelope structure
|
||||
var payload = Convert.ToBase64String(jsonResult.Data!);
|
||||
var envelope = new DsseExportEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.audit-pack+json",
|
||||
Payload = payload,
|
||||
Signatures = [] // Would be populated by actual signing in production
|
||||
};
|
||||
|
||||
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
Success = true,
|
||||
Data = envelopeBytes,
|
||||
ContentType = "application/vnd.dsse+json",
|
||||
Filename = $"{request.Filename}.dsse.json",
|
||||
SizeBytes = envelopeBytes.Length
|
||||
};
|
||||
}
|
||||
|
||||
private static ExportManifest CreateManifest(ExportRequest request)
|
||||
{
|
||||
return new ExportManifest
|
||||
{
|
||||
ExportedAt = DateTimeOffset.UtcNow,
|
||||
ScanId = request.ScanId,
|
||||
FindingIds = request.FindingIds,
|
||||
Format = request.Format.ToString(),
|
||||
Segments = [.. request.Segments.Select(s => s.ToString())],
|
||||
IncludesAttestations = request.IncludeAttestations,
|
||||
IncludesProofChain = request.IncludeProofChain,
|
||||
Version = "1.0"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSegmentPath(ExportSegment segment)
|
||||
{
|
||||
return segment switch
|
||||
{
|
||||
ExportSegment.Sbom => "sbom/sbom.json",
|
||||
ExportSegment.Match => "match/vulnerability-match.json",
|
||||
ExportSegment.Reachability => "reachability/reachability-analysis.json",
|
||||
ExportSegment.Guards => "guards/guard-analysis.json",
|
||||
ExportSegment.Runtime => "runtime/runtime-signals.json",
|
||||
ExportSegment.Policy => "policy/policy-evaluation.json",
|
||||
_ => $"segments/{segment.ToString().ToLowerInvariant()}.json"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<byte[]?> GetSegmentDataAsync(
|
||||
string scanId,
|
||||
ExportSegment segment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_repository is null)
|
||||
{
|
||||
// Return mock data for testing
|
||||
return CreateMockSegmentData(segment);
|
||||
}
|
||||
|
||||
return await _repository.GetSegmentDataAsync(scanId, segment, ct);
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetAttestationsAsync(string scanId, CancellationToken ct)
|
||||
{
|
||||
if (_repository is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var attestations = await _repository.GetAttestationsAsync(scanId, ct);
|
||||
return [.. attestations];
|
||||
}
|
||||
|
||||
private async Task<object?> GetProofChainAsync(string scanId, CancellationToken ct)
|
||||
{
|
||||
if (_repository is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _repository.GetProofChainAsync(scanId, ct);
|
||||
}
|
||||
|
||||
private static byte[] CreateMockSegmentData(ExportSegment segment)
|
||||
{
|
||||
var mockData = new Dictionary<string, object>
|
||||
{
|
||||
["segment"] = segment.ToString(),
|
||||
["generatedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["data"] = new { placeholder = true }
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(mockData, JsonOptions);
|
||||
}
|
||||
|
||||
private static async Task AddJsonToZipAsync<T>(
|
||||
ZipArchive archive,
|
||||
string path,
|
||||
T data,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
|
||||
await using var stream = entry.Open();
|
||||
await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct);
|
||||
}
|
||||
|
||||
private static async Task AddBytesToZipAsync(
|
||||
ZipArchive archive,
|
||||
string path,
|
||||
byte[] data)
|
||||
{
|
||||
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
|
||||
await using var stream = entry.Open();
|
||||
await stream.WriteAsync(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for audit pack export service.
|
||||
/// </summary>
|
||||
public interface IAuditPackExportService
|
||||
{
|
||||
Task<ExportResult> ExportAsync(ExportRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for accessing audit pack data.
|
||||
/// </summary>
|
||||
public interface IAuditPackRepository
|
||||
{
|
||||
Task<byte[]?> GetSegmentDataAsync(string scanId, ExportSegment segment, CancellationToken ct);
|
||||
Task<IReadOnlyList<object>> GetAttestationsAsync(string scanId, CancellationToken ct);
|
||||
Task<object?> GetProofChainAsync(string scanId, CancellationToken ct);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Export format options.
|
||||
/// </summary>
|
||||
public enum ExportFormat
|
||||
{
|
||||
Zip,
|
||||
Json,
|
||||
Dsse
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence segment types for export.
|
||||
/// </summary>
|
||||
public enum ExportSegment
|
||||
{
|
||||
Sbom,
|
||||
Match,
|
||||
Reachability,
|
||||
Guards,
|
||||
Runtime,
|
||||
Policy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for audit pack export.
|
||||
/// </summary>
|
||||
public sealed record ExportRequest
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
public IReadOnlyList<string>? FindingIds { get; init; }
|
||||
public required ExportFormat Format { get; init; }
|
||||
public required IReadOnlyList<ExportSegment> Segments { get; init; }
|
||||
public bool IncludeAttestations { get; init; }
|
||||
public bool IncludeProofChain { get; init; }
|
||||
public required string Filename { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of audit pack export.
|
||||
/// </summary>
|
||||
public sealed record ExportResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public byte[]? Data { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string? Filename { get; init; }
|
||||
public long SizeBytes { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static ExportResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export manifest included in ZIP bundles.
|
||||
/// </summary>
|
||||
public sealed record ExportManifest
|
||||
{
|
||||
public DateTimeOffset ExportedAt { get; init; }
|
||||
public required string ScanId { get; init; }
|
||||
public IReadOnlyList<string>? FindingIds { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public required IReadOnlyList<string> Segments { get; init; }
|
||||
public bool IncludesAttestations { get; init; }
|
||||
public bool IncludesProofChain { get; init; }
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for export.
|
||||
/// </summary>
|
||||
public sealed record DsseExportEnvelope
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayAttestationService.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T7 — Replay attestation generation with DSSE signing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating DSSE-signed attestations for replay executions.
|
||||
/// Produces in-toto v1 statements with verdict replay predicates.
|
||||
/// </summary>
|
||||
public sealed class ReplayAttestationService : IReplayAttestationService
|
||||
{
|
||||
private const string InTotoStatementType = "https://in-toto.io/Statement/v1";
|
||||
private const string VerdictReplayPredicateType = "https://stellaops.io/attestation/verdict-replay/v1";
|
||||
private const string DssePayloadType = "application/vnd.in-toto+json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IReplayAttestationSigner? _signer;
|
||||
|
||||
public ReplayAttestationService(IReplayAttestationSigner? signer = null)
|
||||
{
|
||||
_signer = signer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a DSSE attestation for a replay execution result.
|
||||
/// </summary>
|
||||
public async Task<ReplayAttestation> GenerateAsync(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayExecutionResult replayResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(replayResult);
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = CreateInTotoStatement(manifest, replayResult);
|
||||
|
||||
// Serialize to canonical JSON
|
||||
var statementBytes = JsonSerializer.SerializeToUtf8Bytes(statement, JsonOptions);
|
||||
var statementDigest = ComputeSha256Digest(statementBytes);
|
||||
|
||||
// Create DSSE envelope
|
||||
var envelope = await CreateDsseEnvelopeAsync(statementBytes, cancellationToken);
|
||||
|
||||
return new ReplayAttestation
|
||||
{
|
||||
AttestationId = Guid.NewGuid().ToString("N"),
|
||||
ManifestId = manifest.BundleId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Statement = statement,
|
||||
StatementDigest = statementDigest,
|
||||
Envelope = envelope,
|
||||
Match = replayResult.VerdictMatches && replayResult.DecisionMatches,
|
||||
ReplayStatus = replayResult.Status.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a replay attestation's integrity.
|
||||
/// </summary>
|
||||
public Task<AttestationVerificationResult> VerifyAsync(
|
||||
ReplayAttestation attestation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Verify statement digest
|
||||
var statementBytes = JsonSerializer.SerializeToUtf8Bytes(attestation.Statement, JsonOptions);
|
||||
var computedDigest = ComputeSha256Digest(statementBytes);
|
||||
|
||||
if (computedDigest != attestation.StatementDigest)
|
||||
{
|
||||
errors.Add($"Statement digest mismatch: expected {attestation.StatementDigest}, got {computedDigest}");
|
||||
}
|
||||
|
||||
// Verify envelope payload matches statement
|
||||
if (attestation.Envelope is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(attestation.Envelope.Payload);
|
||||
var payloadDigest = ComputeSha256Digest(payloadBytes);
|
||||
|
||||
if (payloadDigest != computedDigest)
|
||||
{
|
||||
errors.Add("Envelope payload digest does not match statement");
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
errors.Add("Invalid base64 in envelope payload");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signatures if signer is available
|
||||
var signatureValid = attestation.Envelope?.Signatures.Count > 0;
|
||||
|
||||
return Task.FromResult(new AttestationVerificationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = [.. errors],
|
||||
SignatureVerified = signatureValid,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a batch of attestations for multiple replay results.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ReplayAttestation>> GenerateBatchAsync(
|
||||
IEnumerable<(AuditBundleManifest Manifest, ReplayExecutionResult Result)> replays,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attestations = new List<ReplayAttestation>();
|
||||
|
||||
foreach (var (manifest, result) in replays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var attestation = await GenerateAsync(manifest, result, cancellationToken);
|
||||
attestations.Add(attestation);
|
||||
}
|
||||
|
||||
return attestations;
|
||||
}
|
||||
|
||||
private InTotoStatement CreateInTotoStatement(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayExecutionResult replayResult)
|
||||
{
|
||||
return new InTotoStatement
|
||||
{
|
||||
Type = InTotoStatementType,
|
||||
Subject =
|
||||
[
|
||||
new InTotoSubject
|
||||
{
|
||||
Name = $"verdict:{manifest.BundleId}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = manifest.VerdictDigest.Replace("sha256:", "")
|
||||
}
|
||||
}
|
||||
],
|
||||
PredicateType = VerdictReplayPredicateType,
|
||||
Predicate = new VerdictReplayAttestation
|
||||
{
|
||||
ManifestId = manifest.BundleId,
|
||||
ScanId = manifest.ScanId,
|
||||
ImageRef = manifest.ImageRef,
|
||||
ImageDigest = manifest.ImageDigest,
|
||||
InputsDigest = ComputeInputsDigest(manifest.Inputs),
|
||||
OriginalVerdictDigest = manifest.VerdictDigest,
|
||||
ReplayedVerdictDigest = replayResult.ReplayedVerdictDigest,
|
||||
OriginalDecision = manifest.Decision,
|
||||
ReplayedDecision = replayResult.ReplayedDecision,
|
||||
Match = replayResult.VerdictMatches && replayResult.DecisionMatches,
|
||||
Status = replayResult.Status.ToString(),
|
||||
DriftCount = replayResult.Drifts.Count,
|
||||
Drifts = replayResult.Drifts.Select(d => new DriftAttestation
|
||||
{
|
||||
Type = d.Type.ToString(),
|
||||
Field = d.Field,
|
||||
Message = d.Message
|
||||
}).ToList(),
|
||||
EvaluatedAt = replayResult.EvaluatedAt,
|
||||
ReplayedAt = DateTimeOffset.UtcNow,
|
||||
DurationMs = replayResult.DurationMs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ReplayDsseEnvelope> CreateDsseEnvelopeAsync(
|
||||
byte[] payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
var signatures = new List<ReplayDsseSignature>();
|
||||
|
||||
if (_signer is not null)
|
||||
{
|
||||
var signature = await _signer.SignAsync(payload, cancellationToken);
|
||||
signatures.Add(new ReplayDsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Sig = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return new ReplayDsseEnvelope
|
||||
{
|
||||
PayloadType = DssePayloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeInputsDigest(InputDigests inputs)
|
||||
{
|
||||
var combined = $"{inputs.SbomDigest}|{inputs.FeedsDigest}|{inputs.PolicyDigest}|{inputs.VexDigest}";
|
||||
var bytes = Encoding.UTF8.GetBytes(combined);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for replay attestation generation.
|
||||
/// </summary>
|
||||
public interface IReplayAttestationService
|
||||
{
|
||||
Task<ReplayAttestation> GenerateAsync(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayExecutionResult replayResult,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestationVerificationResult> VerifyAsync(
|
||||
ReplayAttestation attestation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ReplayAttestation>> GenerateBatchAsync(
|
||||
IEnumerable<(AuditBundleManifest Manifest, ReplayExecutionResult Result)> replays,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing replay attestations.
|
||||
/// </summary>
|
||||
public interface IReplayAttestationSigner
|
||||
{
|
||||
Task<DsseSignatureResult> SignAsync(byte[] payload, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Generated replay attestation.
|
||||
/// </summary>
|
||||
public sealed record ReplayAttestation
|
||||
{
|
||||
public required string AttestationId { get; init; }
|
||||
public required string ManifestId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required InTotoStatement Statement { get; init; }
|
||||
public required string StatementDigest { get; init; }
|
||||
public ReplayDsseEnvelope? Envelope { get; init; }
|
||||
public bool Match { get; init; }
|
||||
public required string ReplayStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto v1 statement structure.
|
||||
/// </summary>
|
||||
public sealed record InTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required VerdictReplayAttestation Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto subject with name and digest.
|
||||
/// </summary>
|
||||
public sealed record InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict replay predicate for attestation.
|
||||
/// </summary>
|
||||
public sealed record VerdictReplayAttestation
|
||||
{
|
||||
[JsonPropertyName("manifestId")]
|
||||
public required string ManifestId { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public required string ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("inputsDigest")]
|
||||
public required string InputsDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("originalVerdictDigest")]
|
||||
public required string OriginalVerdictDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("replayedVerdictDigest")]
|
||||
public string? ReplayedVerdictDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("originalDecision")]
|
||||
public required string OriginalDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("replayedDecision")]
|
||||
public string? ReplayedDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("match")]
|
||||
public bool Match { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("driftCount")]
|
||||
public int DriftCount { get; init; }
|
||||
|
||||
[JsonPropertyName("drifts")]
|
||||
public IReadOnlyList<DriftAttestation>? Drifts { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("replayedAt")]
|
||||
public DateTimeOffset ReplayedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift item in attestation.
|
||||
/// </summary>
|
||||
public sealed record DriftAttestation
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string? Field { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for replay attestation.
|
||||
/// </summary>
|
||||
public sealed record ReplayDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<ReplayDsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
public sealed record ReplayDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing operation.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureResult
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Signature { get; init; }
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
public bool SignatureVerified { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
399
src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs
Normal file
399
src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs
Normal file
@@ -0,0 +1,399 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayTelemetry.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T10 — Telemetry for replay outcomes
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry instrumentation for verdict replay operations.
|
||||
/// Provides metrics, traces, and structured logging support.
|
||||
/// </summary>
|
||||
public sealed class ReplayTelemetry : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Service name for telemetry identification.
|
||||
/// </summary>
|
||||
public const string ServiceName = "StellaOps.Replay";
|
||||
|
||||
/// <summary>
|
||||
/// Meter name for replay metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Replay";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source name for replay tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Replay";
|
||||
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _replayExecutionsTotal;
|
||||
private readonly Counter<long> _replayMatchesTotal;
|
||||
private readonly Counter<long> _replayDivergencesTotal;
|
||||
private readonly Counter<long> _replayErrorsTotal;
|
||||
private readonly Counter<long> _attestationsGeneratedTotal;
|
||||
private readonly Counter<long> _attestationsVerifiedTotal;
|
||||
private readonly Counter<long> _eligibilityChecksTotal;
|
||||
|
||||
// Histograms
|
||||
private readonly Histogram<double> _replayDurationMs;
|
||||
private readonly Histogram<double> _attestationGenerationDurationMs;
|
||||
private readonly Histogram<int> _driftCount;
|
||||
private readonly Histogram<double> _confidenceScore;
|
||||
|
||||
// Gauges
|
||||
private readonly UpDownCounter<long> _replaysInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for distributed tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ReplayTelemetry class.
|
||||
/// </summary>
|
||||
public ReplayTelemetry(IMeterFactory? meterFactory = null)
|
||||
{
|
||||
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName);
|
||||
|
||||
// Counters
|
||||
_replayExecutionsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.executions.total",
|
||||
unit: "{execution}",
|
||||
description: "Total number of replay executions");
|
||||
|
||||
_replayMatchesTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.matches.total",
|
||||
unit: "{match}",
|
||||
description: "Total number of replay matches (verdict unchanged)");
|
||||
|
||||
_replayDivergencesTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.divergences.total",
|
||||
unit: "{divergence}",
|
||||
description: "Total number of replay divergences detected");
|
||||
|
||||
_replayErrorsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.errors.total",
|
||||
unit: "{error}",
|
||||
description: "Total number of replay errors");
|
||||
|
||||
_attestationsGeneratedTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.attestations.generated.total",
|
||||
unit: "{attestation}",
|
||||
description: "Total number of replay attestations generated");
|
||||
|
||||
_attestationsVerifiedTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.attestations.verified.total",
|
||||
unit: "{verification}",
|
||||
description: "Total number of replay attestations verified");
|
||||
|
||||
_eligibilityChecksTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.replay.eligibility.checks.total",
|
||||
unit: "{check}",
|
||||
description: "Total number of replay eligibility checks");
|
||||
|
||||
// Histograms
|
||||
_replayDurationMs = _meter.CreateHistogram<double>(
|
||||
"stellaops.replay.duration.ms",
|
||||
unit: "ms",
|
||||
description: "Replay execution duration in milliseconds");
|
||||
|
||||
_attestationGenerationDurationMs = _meter.CreateHistogram<double>(
|
||||
"stellaops.replay.attestation.generation.duration.ms",
|
||||
unit: "ms",
|
||||
description: "Attestation generation duration in milliseconds");
|
||||
|
||||
_driftCount = _meter.CreateHistogram<int>(
|
||||
"stellaops.replay.drift.count",
|
||||
unit: "{drift}",
|
||||
description: "Number of drifts detected per replay");
|
||||
|
||||
_confidenceScore = _meter.CreateHistogram<double>(
|
||||
"stellaops.replay.eligibility.confidence",
|
||||
unit: "1",
|
||||
description: "Replay eligibility confidence score distribution");
|
||||
|
||||
// Gauges
|
||||
_replaysInProgress = _meter.CreateUpDownCounter<long>(
|
||||
"stellaops.replay.in_progress",
|
||||
unit: "{replay}",
|
||||
description: "Number of replays currently in progress");
|
||||
}
|
||||
|
||||
#region Replay Execution Metrics
|
||||
|
||||
/// <summary>
|
||||
/// Records the start of a replay execution.
|
||||
/// </summary>
|
||||
public void RecordReplayStarted(string manifestId, string scanId)
|
||||
{
|
||||
_replaysInProgress.Add(1, new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the completion of a replay execution.
|
||||
/// </summary>
|
||||
public void RecordReplayCompleted(
|
||||
string manifestId,
|
||||
string scanId,
|
||||
ReplayOutcome outcome,
|
||||
int driftCount,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId },
|
||||
{ ReplayTelemetryTags.Outcome, outcome.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_replaysInProgress.Add(-1, new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId }
|
||||
});
|
||||
|
||||
_replayExecutionsTotal.Add(1, tags);
|
||||
_replayDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case ReplayOutcome.Match:
|
||||
_replayMatchesTotal.Add(1, tags);
|
||||
break;
|
||||
case ReplayOutcome.Divergence:
|
||||
_replayDivergencesTotal.Add(1, tags);
|
||||
_driftCount.Record(driftCount, tags);
|
||||
break;
|
||||
case ReplayOutcome.Error:
|
||||
_replayErrorsTotal.Add(1, tags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a replay error.
|
||||
/// </summary>
|
||||
public void RecordReplayError(
|
||||
string manifestId,
|
||||
string scanId,
|
||||
string errorCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId },
|
||||
{ ReplayTelemetryTags.ErrorCode, errorCode }
|
||||
};
|
||||
|
||||
_replaysInProgress.Add(-1, new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.ScanId, scanId }
|
||||
});
|
||||
|
||||
_replayErrorsTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Attestation Metrics
|
||||
|
||||
/// <summary>
|
||||
/// Records attestation generation.
|
||||
/// </summary>
|
||||
public void RecordAttestationGenerated(
|
||||
string manifestId,
|
||||
bool match,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.Match, match.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_attestationsGeneratedTotal.Add(1, tags);
|
||||
_attestationGenerationDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records attestation verification.
|
||||
/// </summary>
|
||||
public void RecordAttestationVerified(
|
||||
string attestationId,
|
||||
bool valid)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.AttestationId, attestationId },
|
||||
{ ReplayTelemetryTags.Valid, valid.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_attestationsVerifiedTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Eligibility Metrics
|
||||
|
||||
/// <summary>
|
||||
/// Records an eligibility check.
|
||||
/// </summary>
|
||||
public void RecordEligibilityCheck(
|
||||
string manifestId,
|
||||
bool eligible,
|
||||
double confidenceScore)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ ReplayTelemetryTags.ManifestId, manifestId },
|
||||
{ ReplayTelemetryTags.Eligible, eligible.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_eligibilityChecksTotal.Add(1, tags);
|
||||
_confidenceScore.Record(confidenceScore, tags);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Activity Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for replay execution.
|
||||
/// </summary>
|
||||
public static Activity? StartReplayActivity(string manifestId, string scanId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.Execute");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
activity?.SetTag(ReplayTelemetryTags.ScanId, scanId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for attestation generation.
|
||||
/// </summary>
|
||||
public static Activity? StartAttestationActivity(string manifestId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.GenerateAttestation");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for eligibility check.
|
||||
/// </summary>
|
||||
public static Activity? StartEligibilityActivity(string manifestId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.CheckEligibility");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for divergence detection.
|
||||
/// </summary>
|
||||
public static Activity? StartDivergenceActivity(string manifestId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Replay.DetectDivergence");
|
||||
activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag names for replay telemetry.
|
||||
/// </summary>
|
||||
public static class ReplayTelemetryTags
|
||||
{
|
||||
public const string ManifestId = "manifest_id";
|
||||
public const string ScanId = "scan_id";
|
||||
public const string BundleId = "bundle_id";
|
||||
public const string AttestationId = "attestation_id";
|
||||
public const string Outcome = "outcome";
|
||||
public const string Match = "match";
|
||||
public const string Valid = "valid";
|
||||
public const string Eligible = "eligible";
|
||||
public const string ErrorCode = "error_code";
|
||||
public const string DivergenceType = "divergence_type";
|
||||
public const string DriftType = "drift_type";
|
||||
public const string Severity = "severity";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay outcome values.
|
||||
/// </summary>
|
||||
public enum ReplayOutcome
|
||||
{
|
||||
/// <summary>Verdict matched the original.</summary>
|
||||
Match,
|
||||
|
||||
/// <summary>Divergence detected between original and replayed verdict.</summary>
|
||||
Divergence,
|
||||
|
||||
/// <summary>Replay execution failed with error.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Replay was cancelled.</summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divergence severity levels.
|
||||
/// </summary>
|
||||
public static class DivergenceSeverities
|
||||
{
|
||||
public const string Critical = "critical";
|
||||
public const string High = "high";
|
||||
public const string Medium = "medium";
|
||||
public const string Low = "low";
|
||||
public const string Info = "info";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divergence type values.
|
||||
/// </summary>
|
||||
public static class DivergenceTypes
|
||||
{
|
||||
public const string VerdictDigest = "verdict_digest";
|
||||
public const string Decision = "decision";
|
||||
public const string Confidence = "confidence";
|
||||
public const string Input = "input";
|
||||
public const string Policy = "policy";
|
||||
public const string Evidence = "evidence";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding replay telemetry.
|
||||
/// </summary>
|
||||
public static class ReplayTelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds replay OpenTelemetry instrumentation.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddReplayTelemetry(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ReplayTelemetry>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictReplayPredicate.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T4 — Verdict replay predicate for determining replay eligibility
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a verdict is eligible for replay and
|
||||
/// determines expected outcomes based on input analysis.
|
||||
/// </summary>
|
||||
public sealed class VerdictReplayPredicate : IVerdictReplayPredicate
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a verdict can be replayed given the current inputs.
|
||||
/// </summary>
|
||||
public ReplayEligibility Evaluate(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayInputState? currentInputState = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var reasons = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Check 1: Manifest must have required fields
|
||||
if (string.IsNullOrEmpty(manifest.VerdictDigest))
|
||||
{
|
||||
reasons.Add("Manifest is missing verdict digest");
|
||||
}
|
||||
|
||||
if (manifest.Inputs is null)
|
||||
{
|
||||
reasons.Add("Manifest is missing input digests");
|
||||
}
|
||||
|
||||
// Check 2: Time anchor must be present for deterministic replay
|
||||
if (manifest.TimeAnchor is null)
|
||||
{
|
||||
warnings.Add("No time anchor - replay may produce different results due to time-sensitive data");
|
||||
}
|
||||
|
||||
// Check 3: Verify replay support version
|
||||
if (!string.IsNullOrEmpty(manifest.ReplayVersion))
|
||||
{
|
||||
if (!IsSupportedReplayVersion(manifest.ReplayVersion))
|
||||
{
|
||||
reasons.Add($"Unsupported replay version: {manifest.ReplayVersion}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Compare against current input state if provided
|
||||
if (currentInputState is not null && manifest.Inputs is not null)
|
||||
{
|
||||
var inputDivergence = DetectInputDivergence(manifest.Inputs, currentInputState);
|
||||
if (inputDivergence.HasDivergence)
|
||||
{
|
||||
warnings.AddRange(inputDivergence.Warnings);
|
||||
if (inputDivergence.IsCritical)
|
||||
{
|
||||
reasons.AddRange(inputDivergence.CriticalReasons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Verify policy bundle compatibility
|
||||
if (!string.IsNullOrEmpty(manifest.PolicyVersion))
|
||||
{
|
||||
var policyCheck = CheckPolicyCompatibility(manifest.PolicyVersion);
|
||||
if (!policyCheck.IsCompatible)
|
||||
{
|
||||
reasons.Add(policyCheck.Reason!);
|
||||
}
|
||||
}
|
||||
|
||||
var isEligible = reasons.Count == 0;
|
||||
|
||||
return new ReplayEligibility
|
||||
{
|
||||
IsEligible = isEligible,
|
||||
Reasons = [.. reasons],
|
||||
Warnings = [.. warnings],
|
||||
ExpectedOutcome = isEligible
|
||||
? PredictOutcome(manifest, currentInputState)
|
||||
: null,
|
||||
ConfidenceScore = isEligible
|
||||
? ComputeConfidence(manifest, currentInputState, warnings)
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicts the expected outcome of a replay based on input analysis.
|
||||
/// </summary>
|
||||
public ReplayOutcomePrediction PredictOutcome(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayInputState? currentInputState)
|
||||
{
|
||||
// Default to expecting a match if inputs haven't changed
|
||||
if (currentInputState is null)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
Confidence = 0.5,
|
||||
ExpectedDecision = manifest.Decision,
|
||||
Rationale = "Input state unknown - assuming match"
|
||||
};
|
||||
}
|
||||
|
||||
// Analyze input differences
|
||||
var divergence = DetectInputDivergence(manifest.Inputs!, currentInputState);
|
||||
|
||||
if (!divergence.HasDivergence)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
Confidence = 0.95,
|
||||
ExpectedDecision = manifest.Decision,
|
||||
Rationale = "All inputs match - expecting identical verdict"
|
||||
};
|
||||
}
|
||||
|
||||
// Predict based on divergence type
|
||||
if (divergence.FeedsChanged)
|
||||
{
|
||||
// Feeds changes most likely to cause verdict changes
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Drift,
|
||||
Confidence = 0.7,
|
||||
ExpectedDecision = null, // Unknown - depends on new advisories
|
||||
Rationale = "Vulnerability feeds have changed - verdict may differ",
|
||||
ExpectedDriftTypes = [DriftType.VerdictField, DriftType.Decision]
|
||||
};
|
||||
}
|
||||
|
||||
if (divergence.PolicyChanged)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Drift,
|
||||
Confidence = 0.6,
|
||||
ExpectedDecision = null,
|
||||
Rationale = "Policy rules have changed - decision may differ",
|
||||
ExpectedDriftTypes = [DriftType.Decision]
|
||||
};
|
||||
}
|
||||
|
||||
if (divergence.VexChanged)
|
||||
{
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Drift,
|
||||
Confidence = 0.5,
|
||||
ExpectedDecision = manifest.Decision, // VEX typically doesn't change decision
|
||||
Rationale = "VEX statements have changed - some findings may differ",
|
||||
ExpectedDriftTypes = [DriftType.VerdictField]
|
||||
};
|
||||
}
|
||||
|
||||
// SBOM changes are typically stable
|
||||
return new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
Confidence = 0.8,
|
||||
ExpectedDecision = manifest.Decision,
|
||||
Rationale = "Minor input differences - expecting similar verdict"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two replay execution results and detects divergences.
|
||||
/// </summary>
|
||||
public ReplayDivergenceReport CompareDivergence(
|
||||
ReplayExecutionResult original,
|
||||
ReplayExecutionResult replayed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(original);
|
||||
ArgumentNullException.ThrowIfNull(replayed);
|
||||
|
||||
var divergences = new List<DivergenceItem>();
|
||||
|
||||
// Compare decisions
|
||||
if (original.OriginalDecision != replayed.ReplayedDecision)
|
||||
{
|
||||
divergences.Add(new DivergenceItem
|
||||
{
|
||||
Category = DivergenceCategory.Decision,
|
||||
Field = "decision",
|
||||
OriginalValue = original.OriginalDecision,
|
||||
ReplayedValue = replayed.ReplayedDecision,
|
||||
Severity = DivergenceSeverity.High,
|
||||
Explanation = "Policy decision changed between evaluations"
|
||||
});
|
||||
}
|
||||
|
||||
// Compare verdict digests
|
||||
if (original.OriginalVerdictDigest != replayed.ReplayedVerdictDigest)
|
||||
{
|
||||
divergences.Add(new DivergenceItem
|
||||
{
|
||||
Category = DivergenceCategory.VerdictHash,
|
||||
Field = "verdictDigest",
|
||||
OriginalValue = original.OriginalVerdictDigest,
|
||||
ReplayedValue = replayed.ReplayedVerdictDigest,
|
||||
Severity = DivergenceSeverity.Medium,
|
||||
Explanation = "Verdict content differs (may include new findings or different field values)"
|
||||
});
|
||||
}
|
||||
|
||||
// Include drift items from replay
|
||||
foreach (var drift in replayed.Drifts)
|
||||
{
|
||||
var severity = drift.Type switch
|
||||
{
|
||||
DriftType.Decision => DivergenceSeverity.High,
|
||||
DriftType.VerdictDigest => DivergenceSeverity.Medium,
|
||||
DriftType.InputDigest => DivergenceSeverity.Low,
|
||||
_ => DivergenceSeverity.Low
|
||||
};
|
||||
|
||||
divergences.Add(new DivergenceItem
|
||||
{
|
||||
Category = MapDriftTypeToCategory(drift.Type),
|
||||
Field = drift.Field ?? "unknown",
|
||||
OriginalValue = drift.Expected,
|
||||
ReplayedValue = drift.Actual,
|
||||
Severity = severity,
|
||||
Explanation = drift.Message ?? "Value mismatch detected"
|
||||
});
|
||||
}
|
||||
|
||||
return new ReplayDivergenceReport
|
||||
{
|
||||
HasDivergence = divergences.Count > 0,
|
||||
Divergences = [.. divergences],
|
||||
OverallSeverity = divergences.Count == 0
|
||||
? DivergenceSeverity.None
|
||||
: divergences.Max(d => d.Severity),
|
||||
Summary = GenerateDivergenceSummary(divergences)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSupportedReplayVersion(string version)
|
||||
{
|
||||
// Support replay format versions 1.0 through 2.x
|
||||
return version.StartsWith("1.") || version.StartsWith("2.");
|
||||
}
|
||||
|
||||
private static InputDivergenceResult DetectInputDivergence(
|
||||
InputDigests expected,
|
||||
ReplayInputState current)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var criticalReasons = new List<string>();
|
||||
var hasDivergence = false;
|
||||
|
||||
bool feedsChanged = false, policyChanged = false, vexChanged = false;
|
||||
|
||||
if (current.FeedsDigest is not null && current.FeedsDigest != expected.FeedsDigest)
|
||||
{
|
||||
warnings.Add("Vulnerability feeds have been updated since original evaluation");
|
||||
hasDivergence = true;
|
||||
feedsChanged = true;
|
||||
}
|
||||
|
||||
if (current.PolicyDigest is not null && current.PolicyDigest != expected.PolicyDigest)
|
||||
{
|
||||
warnings.Add("Policy bundle has changed since original evaluation");
|
||||
hasDivergence = true;
|
||||
policyChanged = true;
|
||||
}
|
||||
|
||||
if (current.VexDigest is not null && current.VexDigest != expected.VexDigest)
|
||||
{
|
||||
warnings.Add("VEX statements have been updated since original evaluation");
|
||||
hasDivergence = true;
|
||||
vexChanged = true;
|
||||
}
|
||||
|
||||
if (current.SbomDigest is not null && current.SbomDigest != expected.SbomDigest)
|
||||
{
|
||||
criticalReasons.Add("SBOM differs from original - this is a different artifact");
|
||||
hasDivergence = true;
|
||||
}
|
||||
|
||||
return new InputDivergenceResult
|
||||
{
|
||||
HasDivergence = hasDivergence,
|
||||
IsCritical = criticalReasons.Count > 0,
|
||||
Warnings = warnings,
|
||||
CriticalReasons = criticalReasons,
|
||||
FeedsChanged = feedsChanged,
|
||||
PolicyChanged = policyChanged,
|
||||
VexChanged = vexChanged
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyCompatibility CheckPolicyCompatibility(string policyVersion)
|
||||
{
|
||||
// For now, accept all policy versions
|
||||
// In production, this would check against the policy engine capabilities
|
||||
return new PolicyCompatibility { IsCompatible = true };
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(
|
||||
AuditBundleManifest manifest,
|
||||
ReplayInputState? currentInputState,
|
||||
List<string> warnings)
|
||||
{
|
||||
var confidence = 1.0;
|
||||
|
||||
// Reduce confidence for each warning
|
||||
confidence -= warnings.Count * 0.1;
|
||||
|
||||
// Reduce confidence if no time anchor
|
||||
if (manifest.TimeAnchor is null)
|
||||
{
|
||||
confidence -= 0.15;
|
||||
}
|
||||
|
||||
// Reduce confidence if input state is unknown
|
||||
if (currentInputState is null)
|
||||
{
|
||||
confidence -= 0.2;
|
||||
}
|
||||
|
||||
return Math.Max(0.1, confidence);
|
||||
}
|
||||
|
||||
private static DivergenceCategory MapDriftTypeToCategory(DriftType driftType)
|
||||
{
|
||||
return driftType switch
|
||||
{
|
||||
DriftType.Decision => DivergenceCategory.Decision,
|
||||
DriftType.VerdictDigest => DivergenceCategory.VerdictHash,
|
||||
DriftType.VerdictField => DivergenceCategory.VerdictField,
|
||||
DriftType.InputDigest => DivergenceCategory.Input,
|
||||
_ => DivergenceCategory.Other
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateDivergenceSummary(List<DivergenceItem> divergences)
|
||||
{
|
||||
if (divergences.Count == 0)
|
||||
{
|
||||
return "Replay matched original verdict exactly.";
|
||||
}
|
||||
|
||||
var hasDecisionChange = divergences.Any(d => d.Category == DivergenceCategory.Decision);
|
||||
var hasVerdictChange = divergences.Any(d => d.Category == DivergenceCategory.VerdictHash);
|
||||
var hasInputChange = divergences.Any(d => d.Category == DivergenceCategory.Input);
|
||||
|
||||
if (hasDecisionChange)
|
||||
{
|
||||
return "Replay produced a different policy decision.";
|
||||
}
|
||||
|
||||
if (hasVerdictChange)
|
||||
{
|
||||
return "Replay verdict differs in content but decision is the same.";
|
||||
}
|
||||
|
||||
if (hasInputChange)
|
||||
{
|
||||
return "Input digests differ but verdict is unchanged.";
|
||||
}
|
||||
|
||||
return $"Replay detected {divergences.Count} divergence(s).";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for verdict replay predicate.
|
||||
/// </summary>
|
||||
public interface IVerdictReplayPredicate
|
||||
{
|
||||
ReplayEligibility Evaluate(AuditBundleManifest manifest, ReplayInputState? currentInputState = null);
|
||||
ReplayOutcomePrediction PredictOutcome(AuditBundleManifest manifest, ReplayInputState? currentInputState);
|
||||
ReplayDivergenceReport CompareDivergence(ReplayExecutionResult original, ReplayExecutionResult replayed);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating replay eligibility.
|
||||
/// </summary>
|
||||
public sealed record ReplayEligibility
|
||||
{
|
||||
public bool IsEligible { get; init; }
|
||||
public IReadOnlyList<string> Reasons { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
public ReplayOutcomePrediction? ExpectedOutcome { get; init; }
|
||||
public double ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prediction of replay outcome.
|
||||
/// </summary>
|
||||
public sealed record ReplayOutcomePrediction
|
||||
{
|
||||
public ReplayStatus ExpectedStatus { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public string? ExpectedDecision { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
public IReadOnlyList<DriftType>? ExpectedDriftTypes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current state of replay inputs for comparison.
|
||||
/// </summary>
|
||||
public sealed record ReplayInputState
|
||||
{
|
||||
public string? SbomDigest { get; init; }
|
||||
public string? FeedsDigest { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
public string? VexDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report of divergences between original and replayed evaluations.
|
||||
/// </summary>
|
||||
public sealed record ReplayDivergenceReport
|
||||
{
|
||||
public bool HasDivergence { get; init; }
|
||||
public IReadOnlyList<DivergenceItem> Divergences { get; init; } = [];
|
||||
public DivergenceSeverity OverallSeverity { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual divergence item.
|
||||
/// </summary>
|
||||
public sealed record DivergenceItem
|
||||
{
|
||||
public DivergenceCategory Category { get; init; }
|
||||
public required string Field { get; init; }
|
||||
public string? OriginalValue { get; init; }
|
||||
public string? ReplayedValue { get; init; }
|
||||
public DivergenceSeverity Severity { get; init; }
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of divergence.
|
||||
/// </summary>
|
||||
public enum DivergenceCategory
|
||||
{
|
||||
Decision,
|
||||
VerdictHash,
|
||||
VerdictField,
|
||||
Input,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity of divergence.
|
||||
/// </summary>
|
||||
public enum DivergenceSeverity
|
||||
{
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of input divergence detection.
|
||||
/// </summary>
|
||||
internal sealed record InputDivergenceResult
|
||||
{
|
||||
public bool HasDivergence { get; init; }
|
||||
public bool IsCritical { get; init; }
|
||||
public List<string> Warnings { get; init; } = [];
|
||||
public List<string> CriticalReasons { get; init; } = [];
|
||||
public bool FeedsChanged { get; init; }
|
||||
public bool PolicyChanged { get; init; }
|
||||
public bool VexChanged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy compatibility check.
|
||||
/// </summary>
|
||||
internal sealed record PolicyCompatibility
|
||||
{
|
||||
public bool IsCompatible { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -7,4 +7,8 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
@@ -27,15 +27,15 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitLab" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,20 +7,8 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -6,7 +6,4 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="3.1.0" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||
|
||||
@@ -157,11 +157,6 @@ internal sealed class AwsKmsFacade : IAwsKmsFacade
|
||||
|
||||
private static string ResolveCurve(GetPublicKeyResponse response)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(response.CustomerMasterKeySpec))
|
||||
{
|
||||
return response.CustomerMasterKeySpec;
|
||||
}
|
||||
|
||||
if (response.KeySpec is not null)
|
||||
{
|
||||
var keySpecName = response.KeySpec.ToString();
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="AWSSDK.KeyManagementService" Version="4.0.6" />
|
||||
<PackageReference Include="Google.Cloud.Kms.V1" Version="3.19.0" />
|
||||
<PackageReference Include="Pkcs11Interop" Version="5.1.2" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="AWSSDK.KeyManagementService" />
|
||||
<PackageReference Include="Google.Cloud.Kms.V1" />
|
||||
<PackageReference Include="Pkcs11Interop" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -48,6 +48,9 @@ public sealed class CryptoProGostCryptoProvider : ICryptoProvider, ICryptoProvid
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("CryptoPro provider does not expose password hashing.");
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("CryptoPro provider does not expose content hashing.");
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyReference);
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<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" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net40;net452</TargetFrameworks>
|
||||
@@ -10,10 +10,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="nunit" Version="3.12.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.15.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0-preview.2" PrivateAssets="All" />
|
||||
<PackageReference Include="nunit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -34,4 +33,4 @@
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<GostCryptographyVersion>2.0.11</GostCryptographyVersion>
|
||||
</PropertyGroup>
|
||||
@@ -35,9 +35,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.1" />
|
||||
<PackageReference Include="System.Security.Permissions" Version="8.0.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" />
|
||||
<PackageReference Include="System.Security.Permissions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,22 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -32,5 +22,4 @@
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -8,10 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.X509Certificates" Version="4.3.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="Pkcs11Interop" Version="5.1.2" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
<PackageReference Include="Pkcs11Interop" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,8 +10,10 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Org.BouncyCastle.Pqc.Crypto.Crystals.Dilithium;
|
||||
using Org.BouncyCastle.Pqc.Crypto.Falcon;
|
||||
using Org.BouncyCastle.Crypto.Prng;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
@@ -209,31 +211,18 @@ public sealed class PqSoftCryptoProvider : ICryptoProvider, ICryptoProviderDiagn
|
||||
|
||||
private static PqKeyEntry CreateDilithiumEntry(CryptoSigningKey signingKey)
|
||||
{
|
||||
var parameters = DilithiumParameters.Dilithium3;
|
||||
if (!signingKey.PublicKey.IsEmpty)
|
||||
{
|
||||
var pubFromBytes = new DilithiumPublicKeyParameters(parameters, signingKey.PublicKey.ToArray());
|
||||
var privFromBytes = new DilithiumPrivateKeyParameters(parameters, signingKey.PrivateKey.ToArray(), pubFromBytes);
|
||||
|
||||
var descriptorFromBytes = new CryptoSigningKey(
|
||||
signingKey.Reference,
|
||||
SignatureAlgorithms.Dilithium3,
|
||||
privFromBytes.GetEncoded(),
|
||||
signingKey.CreatedAt,
|
||||
signingKey.ExpiresAt,
|
||||
pubFromBytes.GetEncoded(),
|
||||
signingKey.Metadata);
|
||||
|
||||
return new DilithiumKeyEntry(descriptorFromBytes, privFromBytes, pubFromBytes);
|
||||
}
|
||||
var parameters = MLDsaParameters.ml_dsa_65;
|
||||
|
||||
// Always regenerate keys from deterministic seed - BC 2.5+ API changes
|
||||
// make direct byte reconstruction complex. Seeded generation is deterministic
|
||||
// and will produce the same keys from the same private key seed.
|
||||
var random = CreateSeededRandom(signingKey.PrivateKey);
|
||||
var generator = new DilithiumKeyPairGenerator();
|
||||
generator.Init(new DilithiumKeyGenerationParameters(random, parameters));
|
||||
var generator = new MLDsaKeyPairGenerator();
|
||||
generator.Init(new MLDsaKeyGenerationParameters(random, parameters));
|
||||
var pair = generator.GenerateKeyPair();
|
||||
|
||||
var priv = (DilithiumPrivateKeyParameters)pair.Private;
|
||||
var pub = (DilithiumPublicKeyParameters)pair.Public;
|
||||
var priv = (MLDsaPrivateKeyParameters)pair.Private;
|
||||
var pub = (MLDsaPublicKeyParameters)pair.Public;
|
||||
|
||||
var descriptor = new CryptoSigningKey(
|
||||
signingKey.Reference,
|
||||
@@ -244,7 +233,7 @@ public sealed class PqSoftCryptoProvider : ICryptoProvider, ICryptoProviderDiagn
|
||||
pub.GetEncoded(),
|
||||
signingKey.Metadata);
|
||||
|
||||
return new DilithiumKeyEntry(descriptor, priv, pub);
|
||||
return new MLDsaKeyEntry(descriptor, priv, pub);
|
||||
}
|
||||
|
||||
private static PqKeyEntry CreateFalconEntry(CryptoSigningKey signingKey)
|
||||
@@ -311,13 +300,13 @@ internal abstract record PqKeyEntry(CryptoSigningKey Descriptor, string Algorith
|
||||
public abstract ICryptoSigner CreateSigner();
|
||||
}
|
||||
|
||||
internal sealed record DilithiumKeyEntry(
|
||||
internal sealed record MLDsaKeyEntry(
|
||||
CryptoSigningKey Descriptor,
|
||||
DilithiumPrivateKeyParameters PrivateKey,
|
||||
DilithiumPublicKeyParameters PublicKey)
|
||||
MLDsaPrivateKeyParameters PrivateKey,
|
||||
MLDsaPublicKeyParameters PublicKey)
|
||||
: PqKeyEntry(Descriptor, SignatureAlgorithms.Dilithium3)
|
||||
{
|
||||
public override ICryptoSigner CreateSigner() => new DilithiumSignerWrapper(Descriptor.Reference.KeyId, PrivateKey, PublicKey);
|
||||
public override ICryptoSigner CreateSigner() => new MLDsaSignerWrapper(Descriptor.Reference.KeyId, PrivateKey, PublicKey);
|
||||
}
|
||||
|
||||
internal sealed record FalconKeyEntry(
|
||||
@@ -329,13 +318,13 @@ internal sealed record FalconKeyEntry(
|
||||
public override ICryptoSigner CreateSigner() => new FalconSignerWrapper(Descriptor.Reference.KeyId, PrivateKey, PublicKey);
|
||||
}
|
||||
|
||||
internal sealed class DilithiumSignerWrapper : ICryptoSigner
|
||||
internal sealed class MLDsaSignerWrapper : ICryptoSigner
|
||||
{
|
||||
private readonly string keyId;
|
||||
private readonly DilithiumPrivateKeyParameters privateKey;
|
||||
private readonly DilithiumPublicKeyParameters publicKey;
|
||||
private readonly MLDsaPrivateKeyParameters privateKey;
|
||||
private readonly MLDsaPublicKeyParameters publicKey;
|
||||
|
||||
public DilithiumSignerWrapper(string keyId, DilithiumPrivateKeyParameters privateKey, DilithiumPublicKeyParameters publicKey)
|
||||
public MLDsaSignerWrapper(string keyId, MLDsaPrivateKeyParameters privateKey, MLDsaPublicKeyParameters publicKey)
|
||||
{
|
||||
this.keyId = keyId;
|
||||
this.privateKey = privateKey;
|
||||
@@ -349,17 +338,21 @@ internal sealed class DilithiumSignerWrapper : ICryptoSigner
|
||||
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var signer = new DilithiumSigner();
|
||||
var signer = new MLDsaSigner(MLDsaParameters.ml_dsa_65, deterministic: true);
|
||||
signer.Init(true, privateKey);
|
||||
return ValueTask.FromResult(signer.GenerateSignature(data.ToArray()));
|
||||
var dataArray = data.ToArray();
|
||||
signer.BlockUpdate(dataArray, 0, dataArray.Length);
|
||||
return ValueTask.FromResult(signer.GenerateSignature());
|
||||
}
|
||||
|
||||
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var verifier = new DilithiumSigner();
|
||||
var verifier = new MLDsaSigner(MLDsaParameters.ml_dsa_65, deterministic: true);
|
||||
verifier.Init(false, publicKey);
|
||||
var ok = verifier.VerifySignature(data.ToArray(), signature.ToArray());
|
||||
var dataArray = data.ToArray();
|
||||
verifier.BlockUpdate(dataArray, 0, dataArray.Length);
|
||||
var ok = verifier.VerifySignature(signature.ToArray());
|
||||
return ValueTask.FromResult(ok);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
@@ -25,7 +25,6 @@ public class SmRemoteHttpProviderTests
|
||||
using var app = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(_ => { });
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var client = app.CreateClient();
|
||||
var status = await client.GetFromJsonAsync<SmStatusResponse>("/status");
|
||||
status.Should().NotBeNull();
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\SmRemote\\StellaOps.SmRemote.Service\\StellaOps.SmRemote.Service.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -9,20 +9,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -30,5 +20,4 @@
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -7,10 +7,10 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -10,19 +10,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj" />
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -16,9 +16,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -13,8 +13,17 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider name.
|
||||
/// </summary>
|
||||
public string Name => "offline.verification";
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this provider supports the specified capability and algorithm.
|
||||
/// </summary>
|
||||
/// <param name="capability">The cryptographic capability to check.</param>
|
||||
/// <param name="algorithmId">The algorithm identifier.</param>
|
||||
/// <returns>True if the capability and algorithm are supported; otherwise, false.</returns>
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
@@ -33,6 +42,11 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a password hasher for the specified algorithm.
|
||||
/// </summary>
|
||||
/// <param name="algorithmId">The password hashing algorithm identifier.</param>
|
||||
/// <returns>An instance of <see cref="IPasswordHasher"/>.</returns>
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
{
|
||||
var normalizedAlg = algorithmId.ToUpperInvariant();
|
||||
@@ -45,6 +59,11 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a content hasher for the specified algorithm.
|
||||
/// </summary>
|
||||
/// <param name="algorithmId">The hash algorithm identifier.</param>
|
||||
/// <returns>An instance of <see cref="ICryptoHasher"/>.</returns>
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
{
|
||||
var normalizedAlg = algorithmId.ToUpperInvariant();
|
||||
@@ -57,6 +76,12 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
return new DefaultCryptoHasher(normalizedAlg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a signer for the specified algorithm and key.
|
||||
/// </summary>
|
||||
/// <param name="algorithmId">The signing algorithm identifier.</param>
|
||||
/// <param name="keyReference">The key reference.</param>
|
||||
/// <returns>An instance of <see cref="ICryptoSigner"/>.</returns>
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyReference);
|
||||
@@ -82,6 +107,10 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
return EcdsaSigner.Create(signingKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a signing key into the provider.
|
||||
/// </summary>
|
||||
/// <param name="signingKey">The signing key to add or update.</param>
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
@@ -96,6 +125,11 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a signing key from the provider.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier to remove.</param>
|
||||
/// <returns>True if the key was removed; otherwise, false.</returns>
|
||||
public bool RemoveSigningKey(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
@@ -106,6 +140,10 @@ public sealed class OfflineVerificationCryptoProvider : ICryptoProvider
|
||||
return signingKeys.TryRemove(keyId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all signing keys stored in the provider.
|
||||
/// </summary>
|
||||
/// <returns>A read-only collection of signing keys.</returns>
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> signingKeys.Values.ToArray();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.2" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -43,7 +43,6 @@ public class PolicyProvidersTests
|
||||
|
||||
var provider = new EidasSoftCryptoProvider();
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP384);
|
||||
using StellaOps.TestKit;
|
||||
var key = new CryptoSigningKey(
|
||||
new CryptoKeyReference("eidas-es384"),
|
||||
SignatureAlgorithms.Es384,
|
||||
|
||||
@@ -35,7 +35,7 @@ public class SimRemoteProviderTests
|
||||
public async Task SignAndVerify_WithSimProvider_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
using var services = new ServiceCollection();
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.Configure<SimRemoteProviderOptions>(opts =>
|
||||
{
|
||||
@@ -51,7 +51,6 @@ public class SimRemoteProviderTests
|
||||
services.AddSingleton<SimRemoteProvider>();
|
||||
|
||||
using var providerScope = services.BuildServiceProvider();
|
||||
using StellaOps.TestKit;
|
||||
var provider = providerScope.GetRequiredService<SimRemoteProvider>();
|
||||
var signer = provider.GetSigner("pq.sim", new CryptoKeyReference("sim-key"));
|
||||
var payload = Encoding.UTF8.GetBytes("hello-sim");
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj" />
|
||||
@@ -22,4 +26,4 @@
|
||||
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj" />
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -6,7 +6,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
internal sealed class EcdsaSigner : ICryptoSigner
|
||||
public sealed class EcdsaSigner : ICryptoSigner
|
||||
{
|
||||
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
|
||||
private readonly CryptoSigningKey signingKey;
|
||||
|
||||
@@ -8,6 +8,12 @@ public static class SignatureAlgorithms
|
||||
public const string Es256 = "ES256";
|
||||
public const string Es384 = "ES384";
|
||||
public const string Es512 = "ES512";
|
||||
public const string Rs256 = "RS256";
|
||||
public const string Rs384 = "RS384";
|
||||
public const string Rs512 = "RS512";
|
||||
public const string Ps256 = "PS256";
|
||||
public const string Ps384 = "PS384";
|
||||
public const string Ps512 = "PS512";
|
||||
public const string Ed25519 = "ED25519";
|
||||
public const string EdDsa = "EdDSA";
|
||||
public const string GostR3410_2012_256 = "GOST12-256";
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blake3" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Blake3" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
<Description>Evidence bundle envelope with cryptographic signatures for offline verification</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Core.Tests;
|
||||
@@ -19,7 +20,7 @@ public class EvidenceRecordTests
|
||||
#region ComputeEvidenceId
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_ValidInputs_ReturnsSha256Prefixed()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -36,7 +37,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_SameInputs_ReturnsSameId()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -49,7 +50,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_DifferentSubjects_ReturnsDifferentIds()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("""{"vulnerability":"CVE-2021-44228"}""");
|
||||
@@ -61,7 +62,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_DifferentTypes_ReturnsDifferentIds()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -74,7 +75,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_DifferentPayloads_ReturnsDifferentIds()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -88,7 +89,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_DifferentProvenance_ReturnsDifferentIds()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -115,7 +116,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_NullSubject_ThrowsArgumentException()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("""{"data":"test"}""");
|
||||
@@ -124,7 +125,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_EmptySubject_ThrowsArgumentException()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("""{"data":"test"}""");
|
||||
@@ -133,7 +134,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_NullProvenance_ThrowsArgumentNullException()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("""{"data":"test"}""");
|
||||
@@ -146,7 +147,7 @@ public class EvidenceRecordTests
|
||||
#region Create Factory Method
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Create_ValidInputs_ReturnsRecordWithComputedId()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -169,7 +170,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Create_WithSignatures_IncludesSignatures()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -196,7 +197,7 @@ public class EvidenceRecordTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Create_WithExternalCid_IncludesCid()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -210,7 +211,6 @@ public class EvidenceRecordTests
|
||||
"reachability/v1",
|
||||
externalPayloadCid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi");
|
||||
|
||||
using StellaOps.TestKit;
|
||||
Assert.Equal("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", record.ExternalPayloadCid);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ using StellaOps.TestKit;
|
||||
#region VerifyIntegrity
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyIntegrity_ValidRecord_ReturnsTrue()
|
||||
{
|
||||
var record = EvidenceRecord.Create(
|
||||
@@ -233,7 +233,7 @@ using StellaOps.TestKit;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyIntegrity_TamperedPayload_ReturnsFalse()
|
||||
{
|
||||
var originalPayload = Encoding.UTF8.GetBytes("""{"vulnerability":"CVE-2021-44228"}""");
|
||||
@@ -253,7 +253,7 @@ using StellaOps.TestKit;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyIntegrity_TamperedSubject_ReturnsFalse()
|
||||
{
|
||||
var record = EvidenceRecord.Create(
|
||||
@@ -273,7 +273,7 @@ using StellaOps.TestKit;
|
||||
#region Determinism
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Create_SameInputs_ProducesSameEvidenceId()
|
||||
{
|
||||
var subjectId = "sha256:abc123";
|
||||
@@ -289,7 +289,7 @@ using StellaOps.TestKit;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeEvidenceId_EmptyPayload_Works()
|
||||
{
|
||||
var id = EvidenceRecord.ComputeEvidenceId(
|
||||
|
||||
@@ -9,21 +9,8 @@
|
||||
<RootNamespace>StellaOps.Evidence.Core.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Evidence module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// </summary>
|
||||
public class EvidenceDbContext : DbContext
|
||||
{
|
||||
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("evidence");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Evidence.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Evidence persistence services.
|
||||
/// </summary>
|
||||
public static class EvidencePersistenceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Evidence PostgreSQL persistence services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEvidencePersistence(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "Postgres:Evidence")
|
||||
{
|
||||
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
|
||||
services.TryAddSingleton<EvidenceDataSource>();
|
||||
services.TryAddSingleton<PostgresEvidenceStoreFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Evidence PostgreSQL persistence services with explicit options.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEvidencePersistence(
|
||||
this IServiceCollection services,
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.TryAddSingleton<EvidenceDataSource>();
|
||||
services.TryAddSingleton<PostgresEvidenceStoreFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Evidence.Storage.Postgres;
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Evidence module.
|
||||
@@ -5,7 +5,7 @@ using NpgsqlTypes;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Evidence.Storage.Postgres;
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEvidenceStore"/>.
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Storage.Postgres;
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating tenant-scoped PostgreSQL evidence stores.
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Evidence.Persistence</RootNamespace>
|
||||
<AssemblyName>StellaOps.Evidence.Persistence</AssemblyName>
|
||||
<Description>Consolidated persistence layer for StellaOps Evidence module</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,55 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Evidence.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for Evidence PostgreSQL storage.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Evidence PostgreSQL storage services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Optional: configure PostgreSQL options.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddEvidencePostgresStorage(
|
||||
this IServiceCollection services,
|
||||
Action<PostgresOptions>? configureOptions = null)
|
||||
{
|
||||
// Register PostgreSQL options if not already registered
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
// Register data source as singleton
|
||||
services.TryAddSingleton<EvidenceDataSource>();
|
||||
|
||||
// Register factory for creating tenant-scoped stores
|
||||
services.TryAddSingleton<PostgresEvidenceStoreFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Evidence PostgreSQL storage services with a connection string.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddEvidencePostgresStorage(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
return services.AddEvidencePostgresStorage(options =>
|
||||
{
|
||||
options.ConnectionString = connectionString;
|
||||
options.SchemaName = EvidenceDataSource.DefaultSchemaName;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Evidence.Storage.Postgres</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -7,8 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.2.0" />
|
||||
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Infrastructure.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Base DbContext for StellaOps modules with schema isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived contexts should:
|
||||
/// 1. Override <see cref="SchemaName"/> to specify the module's PostgreSQL schema
|
||||
/// 2. Define DbSet properties for module entities
|
||||
/// 3. Configure compiled model in OnConfiguring if using compiled models
|
||||
/// </remarks>
|
||||
public abstract class StellaOpsDbContextBase : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// PostgreSQL schema name for this module's tables.
|
||||
/// </summary>
|
||||
protected abstract string SchemaName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new DbContext with the specified options.
|
||||
/// </summary>
|
||||
protected StellaOpsDbContextBase(DbContextOptions options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Set the default schema for all entities
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a raw SQL query and returns the result.
|
||||
/// Use for complex queries that don't map well to EF Core (CTEs, window functions, etc.).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Result type.</typeparam>
|
||||
/// <param name="sql">Parameterized SQL query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Query results.</returns>
|
||||
public async Task<List<T>> RawSqlQueryAsync<T>(
|
||||
FormattableString sql,
|
||||
CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
return await Database.SqlQuery<T>(sql).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a raw SQL command (INSERT, UPDATE, DELETE).
|
||||
/// Use for bulk operations or complex mutations.
|
||||
/// </summary>
|
||||
/// <param name="sql">Parameterized SQL command.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of rows affected.</returns>
|
||||
public async Task<int> RawSqlExecuteAsync(
|
||||
FormattableString sql,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await Database.ExecuteSqlInterpolatedAsync(sql, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.EfCore.Interceptors;
|
||||
using StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
namespace StellaOps.Infrastructure.EfCore.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering StellaOps EF Core DbContexts with tenant isolation.
|
||||
/// </summary>
|
||||
public static class DbContextServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a StellaOps DbContext with tenant connection interceptor.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">DbContext type to register.</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="configureOptions">Optional additional configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaOpsDbContext<TContext>(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
Action<DbContextOptionsBuilder>? configureOptions = null)
|
||||
where TContext : DbContext
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
services.AddDbContext<TContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, npgsql =>
|
||||
{
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
|
||||
});
|
||||
|
||||
// Add tenant connection interceptor if tenant accessor is registered
|
||||
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
|
||||
if (tenantAccessor != null)
|
||||
{
|
||||
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
|
||||
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
|
||||
}
|
||||
|
||||
// Enable detailed error messages in development
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging(false); // Keep false for security
|
||||
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a StellaOps DbContext with compiled model and tenant connection interceptor.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">DbContext type to register.</typeparam>
|
||||
/// <typeparam name="TCompiledModel">Compiled model type (implements IModel).</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="compiledModelInstance">Instance of the compiled model.</param>
|
||||
/// <param name="configureOptions">Optional additional configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaOpsDbContextWithCompiledModel<TContext, TCompiledModel>(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
TCompiledModel compiledModelInstance,
|
||||
Action<DbContextOptionsBuilder>? configureOptions = null)
|
||||
where TContext : DbContext
|
||||
where TCompiledModel : class, IModel
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
ArgumentNullException.ThrowIfNull(compiledModelInstance);
|
||||
|
||||
services.AddDbContext<TContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, npgsql =>
|
||||
{
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
|
||||
});
|
||||
|
||||
// Use compiled model for faster startup
|
||||
options.UseModel(compiledModelInstance);
|
||||
|
||||
// Add tenant connection interceptor if tenant accessor is registered
|
||||
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
|
||||
if (tenantAccessor != null)
|
||||
{
|
||||
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
|
||||
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
|
||||
}
|
||||
|
||||
// Enable detailed error messages in development
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging(false);
|
||||
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a DbContext factory for creating DbContext instances.
|
||||
/// Useful for background services and worker scenarios.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">DbContext type to register.</typeparam>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
|
||||
/// <param name="configureOptions">Optional additional configuration.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddStellaOpsDbContextFactory<TContext>(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
Action<DbContextOptionsBuilder>? configureOptions = null)
|
||||
where TContext : DbContext
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
services.AddDbContextFactory<TContext>((sp, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, npgsql =>
|
||||
{
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
|
||||
});
|
||||
|
||||
// Add tenant connection interceptor if tenant accessor is registered
|
||||
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
|
||||
if (tenantAccessor != null)
|
||||
{
|
||||
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
|
||||
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
|
||||
}
|
||||
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging(false);
|
||||
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
namespace StellaOps.Infrastructure.EfCore.Interceptors;
|
||||
|
||||
/// <summary>
|
||||
/// Sets tenant context and session configuration on each PostgreSQL connection.
|
||||
/// Mirrors DataSourceBase.ConfigureSessionAsync() behavior for EF Core contexts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Session settings applied:
|
||||
/// - TIME ZONE 'UTC' for deterministic timestamps
|
||||
/// - app.current_tenant / app.tenant_id for RLS
|
||||
/// - search_path to module schema
|
||||
/// </remarks>
|
||||
public sealed class TenantConnectionInterceptor : DbConnectionInterceptor
|
||||
{
|
||||
private readonly ITenantContextAccessor _tenantAccessor;
|
||||
private readonly string _schemaName;
|
||||
private readonly ILogger<TenantConnectionInterceptor>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new tenant connection interceptor.
|
||||
/// </summary>
|
||||
/// <param name="tenantAccessor">Provider for current tenant context.</param>
|
||||
/// <param name="schemaName">PostgreSQL schema name for search_path.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public TenantConnectionInterceptor(
|
||||
ITenantContextAccessor tenantAccessor,
|
||||
string schemaName,
|
||||
ILogger<TenantConnectionInterceptor>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenantAccessor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
|
||||
_tenantAccessor = tenantAccessor;
|
||||
_schemaName = schemaName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task ConnectionOpenedAsync(
|
||||
DbConnection connection,
|
||||
ConnectionEndEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (connection is not NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = _tenantAccessor.TenantId ?? "_system";
|
||||
|
||||
try
|
||||
{
|
||||
await ConfigureSessionAsync(npgsqlConnection, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex,
|
||||
"Failed to configure PostgreSQL session for tenant {TenantId} in schema {Schema}",
|
||||
tenantId, _schemaName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void ConnectionOpened(
|
||||
DbConnection connection,
|
||||
ConnectionEndEventData eventData)
|
||||
{
|
||||
if (connection is not NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = _tenantAccessor.TenantId ?? "_system";
|
||||
|
||||
try
|
||||
{
|
||||
ConfigureSessionAsync(npgsqlConnection, tenantId, CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex,
|
||||
"Failed to configure PostgreSQL session for tenant {TenantId} in schema {Schema}",
|
||||
tenantId, _schemaName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfigureSessionAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Combine all session configuration into a single command for efficiency
|
||||
var sql = $"""
|
||||
SET TIME ZONE 'UTC';
|
||||
SELECT set_config('app.current_tenant', $1, false),
|
||||
set_config('app.tenant_id', $1, false);
|
||||
SET search_path TO {_schemaName}, public;
|
||||
""";
|
||||
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue(tenantId);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug(
|
||||
"Configured PostgreSQL session: tenant={TenantId}, schema={Schema}",
|
||||
tenantId, _schemaName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Infrastructure.EfCore</RootNamespace>
|
||||
<AssemblyName>StellaOps.Infrastructure.EfCore</AssemblyName>
|
||||
<Description>Shared EF Core infrastructure for StellaOps modules with tenant isolation support</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context accessor using AsyncLocal for tenant propagation across async calls.
|
||||
/// Use this for message handlers, background jobs, and other async scenarios.
|
||||
/// </summary>
|
||||
public sealed class AsyncLocalTenantContextAccessor : ITenantContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<string?> _tenantId = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => _tenantId.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current tenant ID for the async scope.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID to set.</param>
|
||||
public static void SetTenantId(string? tenantId)
|
||||
{
|
||||
_tenantId.Value = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current tenant ID.
|
||||
/// </summary>
|
||||
public static void ClearTenantId()
|
||||
{
|
||||
_tenantId.Value = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that sets the tenant ID and clears it on dispose.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID to set.</param>
|
||||
/// <returns>Disposable scope.</returns>
|
||||
public static IDisposable CreateScope(string tenantId)
|
||||
{
|
||||
return new TenantScope(tenantId);
|
||||
}
|
||||
|
||||
private sealed class TenantScope : IDisposable
|
||||
{
|
||||
private readonly string? _previousTenantId;
|
||||
|
||||
public TenantScope(string tenantId)
|
||||
{
|
||||
_previousTenantId = _tenantId.Value;
|
||||
_tenantId.Value = tenantId;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tenantId.Value = _previousTenantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current tenant context.
|
||||
/// Implement this interface for your specific authentication mechanism (headers, claims, etc.).
|
||||
/// </summary>
|
||||
public interface ITenantContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current tenant ID, or null if not available.
|
||||
/// Returns "_system" for system/admin operations.
|
||||
/// </summary>
|
||||
string? TenantId { get; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Infrastructure.EfCore.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context accessor that always returns "_system".
|
||||
/// Use for background services, migrations, and admin operations.
|
||||
/// </summary>
|
||||
public sealed class SystemTenantContextAccessor : ITenantContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly SystemTenantContextAccessor Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => "_system";
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a dependency between migrations in different modules.
|
||||
/// </summary>
|
||||
public sealed record MigrationDependency
|
||||
{
|
||||
/// <summary>
|
||||
/// The module that has the dependency.
|
||||
/// </summary>
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The migration file that has the dependency.
|
||||
/// </summary>
|
||||
public required string Migration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The module being depended upon.
|
||||
/// </summary>
|
||||
public required string DependsOnModule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The schema being depended upon.
|
||||
/// </summary>
|
||||
public required string DependsOnSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The specific table or object being depended upon (optional).
|
||||
/// </summary>
|
||||
public string? DependsOnObject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a soft dependency (FK created conditionally).
|
||||
/// </summary>
|
||||
public bool IsSoft { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of why this dependency exists.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module schema configuration for dependency resolution.
|
||||
/// </summary>
|
||||
public sealed record ModuleSchemaConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// The module name (e.g., "Authority", "Concelier").
|
||||
/// </summary>
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The PostgreSQL schema name (e.g., "auth", "vuln").
|
||||
/// </summary>
|
||||
public required string Schema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The WebService that owns this module's migrations.
|
||||
/// </summary>
|
||||
public string? OwnerService { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The assembly containing migrations for this module.
|
||||
/// </summary>
|
||||
public string? MigrationAssembly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of module schemas and their dependencies.
|
||||
/// </summary>
|
||||
public sealed class ModuleDependencyRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ModuleSchemaConfig> _modules = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<MigrationDependency> _dependencies = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered modules.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ModuleSchemaConfig> Modules => _modules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered dependencies.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MigrationDependency> Dependencies => _dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a module schema configuration.
|
||||
/// </summary>
|
||||
public ModuleDependencyRegistry RegisterModule(ModuleSchemaConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_modules[config.Module] = config;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a dependency between modules.
|
||||
/// </summary>
|
||||
public ModuleDependencyRegistry RegisterDependency(MigrationDependency dependency)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependency);
|
||||
_dependencies.Add(dependency);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name for a module.
|
||||
/// </summary>
|
||||
public string? GetSchemaForModule(string moduleName)
|
||||
{
|
||||
return _modules.TryGetValue(moduleName, out var config) ? config.Schema : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module name for a schema.
|
||||
/// </summary>
|
||||
public string? GetModuleForSchema(string schemaName)
|
||||
{
|
||||
return _modules.Values
|
||||
.FirstOrDefault(m => string.Equals(m.Schema, schemaName, StringComparison.OrdinalIgnoreCase))
|
||||
?.Module;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets dependencies for a specific module.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MigrationDependency> GetDependenciesForModule(string moduleName)
|
||||
{
|
||||
return _dependencies
|
||||
.Where(d => string.Equals(d.Module, moduleName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets modules that depend on a specific module.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MigrationDependency> GetDependentsOfModule(string moduleName)
|
||||
{
|
||||
return _dependencies
|
||||
.Where(d => string.Equals(d.DependsOnModule, moduleName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all dependencies can be satisfied.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ValidateDependencies()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var dep in _dependencies)
|
||||
{
|
||||
// Check that the dependent module exists
|
||||
if (!_modules.ContainsKey(dep.Module))
|
||||
{
|
||||
errors.Add($"Unknown module '{dep.Module}' in dependency declaration.");
|
||||
}
|
||||
|
||||
// Check that the target module exists
|
||||
if (!_modules.ContainsKey(dep.DependsOnModule))
|
||||
{
|
||||
errors.Add($"Unknown dependency target module '{dep.DependsOnModule}' from '{dep.Module}'.");
|
||||
}
|
||||
|
||||
// Check that the target schema matches
|
||||
if (_modules.TryGetValue(dep.DependsOnModule, out var targetConfig))
|
||||
{
|
||||
if (!string.Equals(targetConfig.Schema, dep.DependsOnSchema, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(
|
||||
$"Schema mismatch for dependency '{dep.Module}' -> '{dep.DependsOnModule}': " +
|
||||
$"expected schema '{targetConfig.Schema}', got '{dep.DependsOnSchema}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default registry with all StellaOps modules.
|
||||
/// </summary>
|
||||
public static ModuleDependencyRegistry CreateDefault()
|
||||
{
|
||||
var registry = new ModuleDependencyRegistry();
|
||||
|
||||
// Register all modules with their schemas
|
||||
registry
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Authority", Schema = "auth", OwnerService = "Authority.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Concelier", Schema = "vuln", OwnerService = "Concelier.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Excititor", Schema = "vex", OwnerService = "Excititor.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Policy", Schema = "policy", OwnerService = "Policy.Gateway" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Scheduler", Schema = "scheduler", OwnerService = "Scheduler.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Notify", Schema = "notify", OwnerService = "Notify.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Scanner", Schema = "scanner", OwnerService = "Scanner.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Attestor", Schema = "proofchain", OwnerService = "Attestor.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Signer", Schema = "signer", OwnerService = "Signer.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Signals", Schema = "signals", OwnerService = "Signals" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "EvidenceLocker", Schema = "evidence", OwnerService = "EvidenceLocker.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "ExportCenter", Schema = "export", OwnerService = "ExportCenter.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "IssuerDirectory", Schema = "issuer", OwnerService = "IssuerDirectory.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Orchestrator", Schema = "orchestrator", OwnerService = "Orchestrator.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Findings", Schema = "findings", OwnerService = "Findings.Ledger.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "BinaryIndex", Schema = "binaries", OwnerService = "Scanner.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "VexHub", Schema = "vexhub", OwnerService = "VexHub.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Unknowns", Schema = "unknowns", OwnerService = "Policy.Gateway" });
|
||||
|
||||
// Register known cross-module dependencies
|
||||
registry
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Signer",
|
||||
Migration = "20251214000001_AddKeyManagementSchema.sql",
|
||||
DependsOnModule = "Attestor",
|
||||
DependsOnSchema = "proofchain",
|
||||
DependsOnObject = "trust_anchors",
|
||||
IsSoft = true,
|
||||
Description = "Optional FK from signer.key_history to proofchain.trust_anchors"
|
||||
})
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Scanner",
|
||||
Migration = "N/A",
|
||||
DependsOnModule = "Concelier",
|
||||
DependsOnSchema = "vuln",
|
||||
IsSoft = true,
|
||||
Description = "Scanner uses Concelier linksets for advisory data"
|
||||
})
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Policy",
|
||||
Migration = "N/A",
|
||||
DependsOnModule = "Concelier",
|
||||
DependsOnSchema = "vuln",
|
||||
IsSoft = true,
|
||||
Description = "Policy uses vulnerability data from Concelier"
|
||||
})
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Policy",
|
||||
Migration = "N/A",
|
||||
DependsOnModule = "Excititor",
|
||||
DependsOnSchema = "vex",
|
||||
IsSoft = true,
|
||||
Description = "Policy uses VEX data from Excititor"
|
||||
});
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the registry to JSON.
|
||||
/// </summary>
|
||||
public string ToJson()
|
||||
{
|
||||
var data = new
|
||||
{
|
||||
modules = _modules.Values.ToList(),
|
||||
dependencies = _dependencies
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry instrumentation for database migrations.
|
||||
/// </summary>
|
||||
public static class MigrationTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the activity source for migration tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Infrastructure.Postgres.Migrations";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the meter for migration metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Infrastructure.Postgres.Migrations";
|
||||
|
||||
private static readonly ActivitySource ActivitySource = new(ActivitySourceName, "1.0.0");
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
// Metrics
|
||||
private static readonly Counter<long> MigrationsAppliedCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.applied.total",
|
||||
description: "Total number of migrations applied");
|
||||
|
||||
private static readonly Counter<long> MigrationsFailedCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.failed.total",
|
||||
description: "Total number of migration failures");
|
||||
|
||||
private static readonly Histogram<double> MigrationDurationHistogram = Meter.CreateHistogram<double>(
|
||||
"stellaops.migrations.duration.seconds",
|
||||
unit: "s",
|
||||
description: "Duration of migration execution");
|
||||
|
||||
private static readonly Counter<long> LockAcquiredCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.lock.acquired.total",
|
||||
description: "Total number of advisory locks acquired");
|
||||
|
||||
private static readonly Counter<long> LockTimeoutCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.lock.timeout.total",
|
||||
description: "Total number of advisory lock timeouts");
|
||||
|
||||
private static readonly UpDownCounter<int> PendingMigrationsGauge = Meter.CreateUpDownCounter<int>(
|
||||
"stellaops.migrations.pending.count",
|
||||
description: "Number of pending migrations");
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for migration execution.
|
||||
/// </summary>
|
||||
public static Activity? StartMigrationRun(string moduleName, string schemaName, int pendingCount)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("migration.run", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("migration.module", moduleName);
|
||||
activity.SetTag("migration.schema", schemaName);
|
||||
activity.SetTag("migration.pending_count", pendingCount);
|
||||
activity.SetTag("db.system", "postgresql");
|
||||
}
|
||||
|
||||
PendingMigrationsGauge.Add(pendingCount, new KeyValuePair<string, object?>("module", moduleName));
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for a single migration.
|
||||
/// </summary>
|
||||
public static Activity? StartMigrationApply(string moduleName, string migrationName, MigrationCategory category)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("migration.apply", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("migration.module", moduleName);
|
||||
activity.SetTag("migration.name", migrationName);
|
||||
activity.SetTag("migration.category", category.ToString().ToLowerInvariant());
|
||||
activity.SetTag("db.system", "postgresql");
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for advisory lock acquisition.
|
||||
/// </summary>
|
||||
public static Activity? StartLockAcquisition(string moduleName, string schemaName)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("migration.lock.acquire", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("migration.module", moduleName);
|
||||
activity.SetTag("migration.schema", schemaName);
|
||||
activity.SetTag("db.system", "postgresql");
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful migration application.
|
||||
/// </summary>
|
||||
public static void RecordMigrationApplied(
|
||||
string moduleName,
|
||||
string migrationName,
|
||||
MigrationCategory category,
|
||||
double durationSeconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "migration", migrationName },
|
||||
{ "category", category.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
MigrationsAppliedCounter.Add(1, tags);
|
||||
MigrationDurationHistogram.Record(durationSeconds, tags);
|
||||
PendingMigrationsGauge.Add(-1, new KeyValuePair<string, object?>("module", moduleName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a migration failure.
|
||||
/// </summary>
|
||||
public static void RecordMigrationFailed(
|
||||
string moduleName,
|
||||
string migrationName,
|
||||
MigrationCategory category,
|
||||
string errorCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "migration", migrationName },
|
||||
{ "category", category.ToString().ToLowerInvariant() },
|
||||
{ "error.code", errorCode }
|
||||
};
|
||||
|
||||
MigrationsFailedCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful lock acquisition.
|
||||
/// </summary>
|
||||
public static void RecordLockAcquired(string moduleName, string schemaName, double waitSeconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "schema", schemaName }
|
||||
};
|
||||
|
||||
LockAcquiredCounter.Add(1, tags);
|
||||
|
||||
// Also record wait time as part of histogram
|
||||
Meter.CreateHistogram<double>("stellaops.migrations.lock.wait.seconds", unit: "s")
|
||||
.Record(waitSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a lock acquisition timeout.
|
||||
/// </summary>
|
||||
public static void RecordLockTimeout(string moduleName, string schemaName)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "schema", schemaName }
|
||||
};
|
||||
|
||||
LockTimeoutCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a checksum validation error.
|
||||
/// </summary>
|
||||
public static void RecordChecksumError(string moduleName, string migrationName)
|
||||
{
|
||||
Meter.CreateCounter<long>("stellaops.migrations.checksum.errors.total")
|
||||
.Add(1, new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "migration", migrationName }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error on an activity.
|
||||
/// </summary>
|
||||
public static void SetActivityError(Activity? activity, Exception exception)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Error, exception.Message);
|
||||
activity.SetTag("error.type", exception.GetType().FullName);
|
||||
activity.SetTag("error.message", exception.Message);
|
||||
activity.SetTag("exception.stacktrace", exception.StackTrace);
|
||||
|
||||
// Add exception event for OpenTelemetry compatibility
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ "exception.type", exception.GetType().FullName },
|
||||
{ "exception.message", exception.Message }
|
||||
};
|
||||
activity.AddEvent(new ActivityEvent("exception", tags: tags));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an activity as successful.
|
||||
/// </summary>
|
||||
public static void SetActivitySuccess(Activity? activity, int appliedCount)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
activity.SetTag("migration.applied_count", appliedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Validates migration files for naming conventions, duplicates, and ordering issues.
|
||||
/// </summary>
|
||||
public static partial class MigrationValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard migration pattern: NNN_description.sql (001-099 for startup, 100+ for release).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex StandardPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Seed migration pattern: SNNN_description.sql.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^S(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex SeedPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Data migration pattern: DMNNN_description.sql.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^DM(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex DataMigrationPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for a set of migrations.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
public IReadOnlyList<ValidationError> Errors { get; init; } = [];
|
||||
public IReadOnlyList<ValidationWarning> Warnings { get; init; } = [];
|
||||
|
||||
public static ValidationResult Success(IReadOnlyList<ValidationWarning>? warnings = null) =>
|
||||
new() { Warnings = warnings ?? [] };
|
||||
|
||||
public static ValidationResult Failed(IReadOnlyList<ValidationError> errors, IReadOnlyList<ValidationWarning>? warnings = null) =>
|
||||
new() { Errors = errors, Warnings = warnings ?? [] };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error that will block migration execution.
|
||||
/// </summary>
|
||||
public sealed record ValidationError(string Code, string Message, string? MigrationName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Validation warning that should be addressed but won't block execution.
|
||||
/// </summary>
|
||||
public sealed record ValidationWarning(string Code, string Message, string? MigrationName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a collection of migration file names.
|
||||
/// </summary>
|
||||
public static ValidationResult Validate(IEnumerable<string> migrationNames)
|
||||
{
|
||||
var names = migrationNames.ToList();
|
||||
var errors = new List<ValidationError>();
|
||||
var warnings = new List<ValidationWarning>();
|
||||
|
||||
// Check for duplicates (same numeric prefix)
|
||||
var duplicates = DetectDuplicatePrefixes(names);
|
||||
foreach (var (prefix, duplicateNames) in duplicates)
|
||||
{
|
||||
errors.Add(new ValidationError(
|
||||
"DUPLICATE_PREFIX",
|
||||
$"Multiple migrations with prefix '{prefix}': {string.Join(", ", duplicateNames)}",
|
||||
duplicateNames.First()));
|
||||
}
|
||||
|
||||
// Check naming conventions
|
||||
foreach (var name in names)
|
||||
{
|
||||
var conventionResult = ValidateNamingConvention(name);
|
||||
if (conventionResult is not null)
|
||||
{
|
||||
if (conventionResult.Value.IsError)
|
||||
{
|
||||
errors.Add(new ValidationError(conventionResult.Value.Code, conventionResult.Value.Message, name));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add(new ValidationWarning(conventionResult.Value.Code, conventionResult.Value.Message, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for gaps in numbering
|
||||
var gaps = DetectNumberingGaps(names);
|
||||
foreach (var gap in gaps)
|
||||
{
|
||||
warnings.Add(new ValidationWarning(
|
||||
"NUMBERING_GAP",
|
||||
$"Gap in migration numbering: {gap.After} is followed by {gap.Before} (missing {gap.Missing})",
|
||||
gap.Before));
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidationResult.Failed(errors, warnings)
|
||||
: ValidationResult.Success(warnings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects migrations with duplicate numeric prefixes.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(string Prefix, IReadOnlyList<string> Names)> DetectDuplicatePrefixes(
|
||||
IEnumerable<string> migrationNames)
|
||||
{
|
||||
var byPrefix = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var name in migrationNames)
|
||||
{
|
||||
var prefix = ExtractNumericPrefix(name);
|
||||
if (prefix is null) continue;
|
||||
|
||||
if (!byPrefix.TryGetValue(prefix, out var list))
|
||||
{
|
||||
list = [];
|
||||
byPrefix[prefix] = list;
|
||||
}
|
||||
list.Add(name);
|
||||
}
|
||||
|
||||
return byPrefix
|
||||
.Where(kvp => kvp.Value.Count > 1)
|
||||
.Select(kvp => (kvp.Key, (IReadOnlyList<string>)kvp.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the numeric prefix from a migration name.
|
||||
/// </summary>
|
||||
public static string? ExtractNumericPrefix(string migrationName)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(migrationName);
|
||||
|
||||
// Handle seed migrations (S001, S002, etc.)
|
||||
if (name.StartsWith('S') && char.IsDigit(name.ElementAtOrDefault(1)))
|
||||
{
|
||||
return "S" + new string(name.Skip(1).TakeWhile(char.IsDigit).ToArray());
|
||||
}
|
||||
|
||||
// Handle data migrations (DM001, DM002, etc.)
|
||||
if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(2)))
|
||||
{
|
||||
return "DM" + new string(name.Skip(2).TakeWhile(char.IsDigit).ToArray());
|
||||
}
|
||||
|
||||
// Handle standard migrations (001, 002, etc.)
|
||||
var digits = new string(name.TakeWhile(char.IsDigit).ToArray());
|
||||
return string.IsNullOrEmpty(digits) ? null : digits.TrimStart('0').PadLeft(3, '0');
|
||||
}
|
||||
|
||||
private static (bool IsError, string Code, string Message)? ValidateNamingConvention(string migrationName)
|
||||
{
|
||||
var name = Path.GetFileName(migrationName);
|
||||
|
||||
// Check standard pattern
|
||||
if (StandardPattern().IsMatch(name))
|
||||
{
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
// Check seed pattern
|
||||
if (SeedPattern().IsMatch(name))
|
||||
{
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
// Check data migration pattern
|
||||
if (DataMigrationPattern().IsMatch(name))
|
||||
{
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
// Check for non-standard but common patterns
|
||||
if (name.StartsWith("V", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (false, "FLYWAY_STYLE", $"Migration '{name}' uses Flyway-style naming. Consider standardizing to NNN_description.sql format.");
|
||||
}
|
||||
|
||||
if (name.Length > 15 && char.IsDigit(name[0]) && name.Contains("_"))
|
||||
{
|
||||
// Likely EF Core timestamp pattern like 20251214000001_AddSchema.sql
|
||||
return (false, "EFCORE_STYLE", $"Migration '{name}' uses EF Core timestamp naming. Consider standardizing to NNN_description.sql format.");
|
||||
}
|
||||
|
||||
// Check for 4-digit prefixes (like 0059_scans_table.sql)
|
||||
var fourDigitMatch = System.Text.RegularExpressions.Regex.Match(name, @"^(\d{4})_");
|
||||
if (fourDigitMatch.Success)
|
||||
{
|
||||
return (false, "FOUR_DIGIT_PREFIX", $"Migration '{name}' uses 4-digit prefix. Standard is 3-digit (NNN_description.sql).");
|
||||
}
|
||||
|
||||
return (false, "NON_STANDARD_NAME", $"Migration '{name}' does not match standard naming pattern (NNN_description.sql).");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string After, string Before, string Missing)> DetectNumberingGaps(
|
||||
IEnumerable<string> migrationNames)
|
||||
{
|
||||
var gaps = new List<(string, string, string)>();
|
||||
var standardMigrations = new List<(int Number, string Name)>();
|
||||
|
||||
foreach (var name in migrationNames)
|
||||
{
|
||||
var prefix = ExtractNumericPrefix(name);
|
||||
if (prefix is null) continue;
|
||||
|
||||
// Only check standard migrations (not S or DM)
|
||||
if (prefix.StartsWith('S') || prefix.StartsWith("DM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (int.TryParse(prefix, out var num))
|
||||
{
|
||||
standardMigrations.Add((num, name));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = standardMigrations.OrderBy(m => m.Number).ToList();
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var prev = sorted[i - 1];
|
||||
var curr = sorted[i];
|
||||
var expected = prev.Number + 1;
|
||||
|
||||
if (curr.Number > expected && curr.Number - prev.Number > 1)
|
||||
{
|
||||
var missing = expected == curr.Number - 1
|
||||
? expected.ToString("D3")
|
||||
: $"{expected:D3}-{(curr.Number - 1):D3}";
|
||||
gaps.Add((prev.Name, curr.Name, missing));
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Interop;
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Provides atomic token issuance and consumption.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TokenEntry<TPayload>> _store;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAtomicTokenStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_store = registry.GetOrCreateTokenStore<TPayload>(name);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
// Generate secure random token
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var entry = new TokenEntry<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
// Try to add, or update if already exists
|
||||
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
|
||||
|
||||
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var entry = new TokenEntry<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
|
||||
|
||||
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Try to get and remove atomically
|
||||
if (!_store.TryGetValue(fullKey, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (entry.ExpiresAt < now)
|
||||
{
|
||||
_store.TryRemove(fullKey, out _);
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Expired(entry.IssuedAt, entry.ExpiresAt));
|
||||
}
|
||||
|
||||
// Check token match
|
||||
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Mismatch());
|
||||
}
|
||||
|
||||
// Atomically remove if token still matches
|
||||
if (_store.TryRemove(fullKey, out var removed) && string.Equals(removed.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Success(
|
||||
removed.Payload,
|
||||
removed.IssuedAt,
|
||||
removed.ExpiresAt));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_store.TryGetValue(fullKey, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt < now)
|
||||
{
|
||||
_store.TryRemove(fullKey, out _);
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_store.TryRemove(fullKey, out _));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAtomicTokenStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryAtomicTokenStore<TPayload>(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryCacheFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryCacheStore<TKey, TValue>(_registry, options, null, _timeProvider);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryCacheStore<TValue>(_registry, options, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class InMemoryCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly CacheOptions _options;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryCacheStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
CacheOptions options,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
private string CacheName => _options.KeyPrefix ?? "default";
|
||||
private ConcurrentDictionary<string, object> Cache => _registry.GetOrCreateCache(CacheName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
|
||||
if (Cache.TryGetValue(cacheKey, out var obj) && obj is CacheEntry<TValue> entry)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check expiration
|
||||
if (entry.ExpiresAt.HasValue && entry.ExpiresAt.Value < now)
|
||||
{
|
||||
Cache.TryRemove(cacheKey, out _);
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Miss());
|
||||
}
|
||||
|
||||
// Handle sliding expiration
|
||||
if (_options.SlidingExpiration && _options.DefaultTtl.HasValue)
|
||||
{
|
||||
entry.ExpiresAt = now.Add(_options.DefaultTtl.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Found(entry.Value));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Miss());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
DateTimeOffset? expiresAt = null;
|
||||
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiresAt = now.Add(options.TimeToLive.Value);
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiresAt = options.AbsoluteExpiration.Value;
|
||||
}
|
||||
else if (_options.DefaultTtl.HasValue)
|
||||
{
|
||||
expiresAt = now.Add(_options.DefaultTtl.Value);
|
||||
}
|
||||
|
||||
var entry = new CacheEntry<TValue> { Value = value, ExpiresAt = expiresAt };
|
||||
Cache[cacheKey] = entry;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
return ValueTask.FromResult(Cache.TryRemove(cacheKey, out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simple pattern matching - supports * at start or end
|
||||
var prefix = _options.KeyPrefix ?? string.Empty;
|
||||
var fullPattern = $"{prefix}{pattern}";
|
||||
|
||||
long count = 0;
|
||||
foreach (var key in Cache.Keys.ToList())
|
||||
{
|
||||
if (MatchesPattern(key, fullPattern))
|
||||
{
|
||||
if (Cache.TryRemove(key, out _))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_options.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_options.KeyPrefix}{keyString}";
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string input, string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
|
||||
{
|
||||
return input.Contains(pattern[1..^1], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.StartsWith('*'))
|
||||
{
|
||||
return input.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.EndsWith('*'))
|
||||
{
|
||||
return input.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(input, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class CacheEntry<T>
|
||||
{
|
||||
public required T Value { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed in-memory cache store.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class InMemoryCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly InMemoryCacheStore<string, TValue> _inner;
|
||||
|
||||
public InMemoryCacheStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
CacheOptions options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new InMemoryCacheStore<string, TValue>(registry, options, key => key, timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Provides fire-and-forget event publishing with subscription support.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly EventStreamStore<TEvent> _store;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryEventStream(
|
||||
InMemoryQueueRegistry registry,
|
||||
EventStreamOptions options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_store = registry.GetOrCreateEventStream<TEvent>(options.StreamName);
|
||||
_options = options;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entryId = _store.Add(
|
||||
@event,
|
||||
options?.TenantId,
|
||||
options?.CorrelationId,
|
||||
options?.Headers,
|
||||
now);
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
_store.Trim(_options.MaxLength.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(EventPublishResult.Succeeded(entryId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var entryId = _store.Add(
|
||||
@event,
|
||||
options?.TenantId,
|
||||
options?.CorrelationId,
|
||||
options?.Headers,
|
||||
now);
|
||||
results.Add(EventPublishResult.Succeeded(entryId));
|
||||
}
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
_store.Trim(_options.MaxLength.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<EventPublishResult>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? lastEntryId = position.Value == "$" ? null : position.Value;
|
||||
|
||||
// First, yield existing entries after the position
|
||||
if (position.Value != "$")
|
||||
{
|
||||
var existingEntries = _store.GetEntriesAfter(lastEntryId);
|
||||
foreach (var entry in existingEntries)
|
||||
{
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entry.EntryId,
|
||||
entry.Event,
|
||||
entry.Timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
lastEntryId = entry.EntryId;
|
||||
}
|
||||
}
|
||||
|
||||
// Then subscribe to new entries
|
||||
var reader = _store.Reader;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// WaitToReadAsync will throw OperationCanceledException when cancelled,
|
||||
// which will naturally end the async enumeration
|
||||
if (!await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
while (reader.TryRead(out var entry))
|
||||
{
|
||||
// Skip entries we've already seen
|
||||
if (lastEntryId != null && string.CompareOrdinal(entry.EntryId, lastEntryId) <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entry.EntryId,
|
||||
entry.Event,
|
||||
entry.Timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
lastEntryId = entry.EntryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (length, firstId, lastId, firstTs, lastTs) = _store.GetInfo();
|
||||
return ValueTask.FromResult(new StreamInfo(length, firstId, lastId, firstTs, lastTs));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.Trim(maxLength));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory event stream instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryEventStreamFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryEventStream<TEvent>(_registry, options, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Provides idempotency key management for deduplication.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIdempotencyStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(window);
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
if (_registry.TryClaimIdempotencyKey(fullKey, value, expiresAt, out var existingValue))
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(existingValue ?? string.Empty));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
return ValueTask.FromResult(_registry.IdempotencyKeyExists(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
return ValueTask.FromResult(_registry.GetIdempotencyKey(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.ReleaseIdempotencyKey(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.ExtendIdempotencyKey(fullKey, extension, _timeProvider));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIdempotencyStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryIdempotencyStore(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class InMemoryMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly InMemoryMessageQueue<TMessage> _queue;
|
||||
private readonly InMemoryQueueEntry<TMessage> _entry;
|
||||
private int _completed;
|
||||
|
||||
internal InMemoryMessageLease(
|
||||
InMemoryMessageQueue<TMessage> queue,
|
||||
InMemoryQueueEntry<TMessage> entry,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
_entry = entry;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId => _entry.MessageId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message => _entry.Message;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt => _entry.Attempt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt => _entry.EnqueuedAt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => _entry.TenantId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId => _entry.CorrelationId;
|
||||
|
||||
internal InMemoryQueueEntry<TMessage> Entry => _entry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(extension);
|
||||
_entry.LeaseExpiresAt = LeaseExpiresAt;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> _entry.Attempt++;
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IMessageQueue{TMessage}"/>.
|
||||
/// Useful for testing and development scenarios.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class InMemoryMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly MessageQueueOptions _options;
|
||||
private readonly ILogger<InMemoryMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _idempotencyKeys = new(StringComparer.Ordinal);
|
||||
|
||||
private long _messageIdCounter;
|
||||
|
||||
public InMemoryMessageQueue(
|
||||
InMemoryQueueRegistry registry,
|
||||
MessageQueueOptions options,
|
||||
ILogger<InMemoryMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _options.QueueName;
|
||||
|
||||
private Channel<InMemoryQueueEntry<TMessage>> Queue => _registry.GetOrCreateQueue<TMessage>(_options.QueueName);
|
||||
private ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> Pending => _registry.GetOrCreatePending<TMessage>(_options.QueueName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
// Check idempotency
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_idempotencyKeys.TryGetValue(options.IdempotencyKey, out var existingTime))
|
||||
{
|
||||
if (now - existingTime < _options.IdempotencyWindow)
|
||||
{
|
||||
return EnqueueResult.Duplicate($"inmem-{options.IdempotencyKey}");
|
||||
}
|
||||
}
|
||||
_idempotencyKeys[options.IdempotencyKey] = now;
|
||||
}
|
||||
|
||||
var messageId = $"inmem-{Interlocked.Increment(ref _messageIdCounter)}";
|
||||
var entry = new InMemoryQueueEntry<TMessage>
|
||||
{
|
||||
MessageId = messageId,
|
||||
Message = message,
|
||||
Attempt = 1,
|
||||
EnqueuedAt = _timeProvider.GetUtcNow(),
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
IdempotencyKey = options?.IdempotencyKey,
|
||||
Headers = options?.Headers
|
||||
};
|
||||
|
||||
await Queue.Writer.WriteAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _options.QueueName);
|
||||
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
|
||||
|
||||
// First check pending (for redeliveries)
|
||||
if (request.PendingOnly)
|
||||
{
|
||||
foreach (var kvp in Pending)
|
||||
{
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entry = kvp.Value;
|
||||
if (entry.LeaseExpiresAt.HasValue && entry.LeaseExpiresAt.Value < now)
|
||||
{
|
||||
// Expired lease - claim it
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
entry.Attempt++;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
}
|
||||
return leases;
|
||||
}
|
||||
|
||||
// Try to read new messages
|
||||
for (var i = 0; i < request.BatchSize; i++)
|
||||
{
|
||||
if (Queue.Reader.TryRead(out var entry))
|
||||
{
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
Pending[entry.MessageId] = entry;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
|
||||
|
||||
foreach (var kvp in Pending)
|
||||
{
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entry = kvp.Value;
|
||||
if (entry.LeaseExpiresAt.HasValue &&
|
||||
now - entry.LeaseExpiresAt.Value >= request.MinIdleTime &&
|
||||
entry.Attempt >= request.MinDeliveryAttempts)
|
||||
{
|
||||
// Claim it
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
entry.Attempt++;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>(leases);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult((long)Pending.Count);
|
||||
}
|
||||
|
||||
internal ValueTask AcknowledgeAsync(InMemoryMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _options.QueueName);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
InMemoryMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _options.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
lease.IncrementAttempt();
|
||||
lease.Entry.LeasedBy = null;
|
||||
lease.Entry.LeaseExpiresAt = null;
|
||||
|
||||
// Re-enqueue
|
||||
await Queue.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(InMemoryMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.DeadLetterQueue))
|
||||
{
|
||||
var dlqChannel = _registry.GetOrCreateQueue<TMessage>(_options.DeadLetterQueue);
|
||||
lease.Entry.LeasedBy = null;
|
||||
lease.Entry.LeaseExpiresAt = null;
|
||||
await dlqChannel.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Dropped message {MessageId} (no DLQ configured): {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory message queue instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryMessageQueueFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new InMemoryMessageQueue<TMessage>(
|
||||
_registry,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<InMemoryMessageQueue<TMessage>>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,741 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Shared registry for in-memory queues. Enables message passing between
|
||||
/// producers and consumers in the same process (useful for testing).
|
||||
/// </summary>
|
||||
public sealed class InMemoryQueueRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _queues = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _pendingMessages = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, object>> _caches = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, RateLimitBucket> _rateLimitBuckets = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _tokenStores = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _sortedIndexes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _setStores = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _eventStreams = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, IdempotencyEntry> _idempotencyKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a queue channel for the specified queue name.
|
||||
/// </summary>
|
||||
public Channel<InMemoryQueueEntry<TMessage>> GetOrCreateQueue<TMessage>(string queueName)
|
||||
where TMessage : class
|
||||
{
|
||||
return (Channel<InMemoryQueueEntry<TMessage>>)_queues.GetOrAdd(
|
||||
queueName,
|
||||
_ => Channel.CreateUnbounded<InMemoryQueueEntry<TMessage>>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the pending messages dictionary for a queue.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> GetOrCreatePending<TMessage>(string queueName)
|
||||
where TMessage : class
|
||||
{
|
||||
return (ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>)_pendingMessages.GetOrAdd(
|
||||
queueName,
|
||||
_ => new ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a cache dictionary for the specified cache name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, object> GetOrCreateCache(string cacheName)
|
||||
{
|
||||
return _caches.GetOrAdd(cacheName, _ => new ConcurrentDictionary<string, object>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all queues and caches (useful for test cleanup).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_queues.Clear();
|
||||
_pendingMessages.Clear();
|
||||
_caches.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a specific queue.
|
||||
/// </summary>
|
||||
public void ClearQueue(string queueName)
|
||||
{
|
||||
_queues.TryRemove(queueName, out _);
|
||||
_pendingMessages.TryRemove(queueName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a specific cache.
|
||||
/// </summary>
|
||||
public void ClearCache(string cacheName)
|
||||
{
|
||||
_caches.TryRemove(cacheName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a rate limit bucket for the specified key.
|
||||
/// </summary>
|
||||
public RateLimitBucket GetOrCreateRateLimitBucket(string key)
|
||||
{
|
||||
return _rateLimitBuckets.GetOrAdd(key, _ => new RateLimitBucket());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a rate limit bucket.
|
||||
/// </summary>
|
||||
public bool RemoveRateLimitBucket(string key)
|
||||
{
|
||||
return _rateLimitBuckets.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a token store for the specified name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, TokenEntry<TPayload>> GetOrCreateTokenStore<TPayload>(string name)
|
||||
{
|
||||
return (ConcurrentDictionary<string, TokenEntry<TPayload>>)_tokenStores.GetOrAdd(
|
||||
name,
|
||||
_ => new ConcurrentDictionary<string, TokenEntry<TPayload>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a sorted index for the specified name.
|
||||
/// </summary>
|
||||
public SortedIndexStore<TKey, TElement> GetOrCreateSortedIndex<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
return (SortedIndexStore<TKey, TElement>)_sortedIndexes.GetOrAdd(
|
||||
name,
|
||||
_ => new SortedIndexStore<TKey, TElement>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a set store for the specified name.
|
||||
/// </summary>
|
||||
public SetStoreData<TKey, TElement> GetOrCreateSetStore<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
return (SetStoreData<TKey, TElement>)_setStores.GetOrAdd(
|
||||
name,
|
||||
_ => new SetStoreData<TKey, TElement>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an event stream for the specified name.
|
||||
/// </summary>
|
||||
public EventStreamStore<TEvent> GetOrCreateEventStream<TEvent>(string name)
|
||||
where TEvent : class
|
||||
{
|
||||
return (EventStreamStore<TEvent>)_eventStreams.GetOrAdd(
|
||||
name,
|
||||
_ => new EventStreamStore<TEvent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to claim an idempotency key.
|
||||
/// </summary>
|
||||
public bool TryClaimIdempotencyKey(string key, string value, DateTimeOffset expiresAt, out string? existingValue)
|
||||
{
|
||||
var entry = new IdempotencyEntry { Value = value, ExpiresAt = expiresAt };
|
||||
|
||||
if (_idempotencyKeys.TryAdd(key, entry))
|
||||
{
|
||||
existingValue = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_idempotencyKeys.TryGetValue(key, out var existing))
|
||||
{
|
||||
existingValue = existing.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
existingValue = null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an idempotency key exists.
|
||||
/// </summary>
|
||||
public bool IdempotencyKeyExists(string key)
|
||||
{
|
||||
return _idempotencyKeys.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an idempotency key value.
|
||||
/// </summary>
|
||||
public string? GetIdempotencyKey(string key)
|
||||
{
|
||||
return _idempotencyKeys.TryGetValue(key, out var entry) ? entry.Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases an idempotency key.
|
||||
/// </summary>
|
||||
public bool ReleaseIdempotencyKey(string key)
|
||||
{
|
||||
return _idempotencyKeys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extends an idempotency key's expiration.
|
||||
/// </summary>
|
||||
public bool ExtendIdempotencyKey(string key, TimeSpan extension, TimeProvider timeProvider)
|
||||
{
|
||||
if (_idempotencyKeys.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.ExpiresAt = timeProvider.GetUtcNow().Add(extension);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired idempotency keys.
|
||||
/// </summary>
|
||||
public void CleanupExpiredIdempotencyKeys(DateTimeOffset now)
|
||||
{
|
||||
var expiredKeys = _idempotencyKeys
|
||||
.Where(kvp => kvp.Value.ExpiresAt < now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_idempotencyKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit bucket for sliding window tracking.
|
||||
/// </summary>
|
||||
public sealed class RateLimitBucket
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<DateTimeOffset> _timestamps = [];
|
||||
|
||||
public int GetCount(DateTimeOffset windowStart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupOld(windowStart);
|
||||
return _timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public int Increment(DateTimeOffset now, DateTimeOffset windowStart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupOld(windowStart);
|
||||
_timestamps.Add(now);
|
||||
return _timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_timestamps.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOld(DateTimeOffset windowStart)
|
||||
{
|
||||
_timestamps.RemoveAll(t => t < windowStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token entry for atomic token store.
|
||||
/// </summary>
|
||||
public sealed class TokenEntry<TPayload>
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public required TPayload Payload { get; init; }
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency entry.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyEntry
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorted index storage with score-based ordering.
|
||||
/// </summary>
|
||||
public sealed class SortedIndexStore<TKey, TElement> where TKey : notnull where TElement : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<TKey, SortedIndexData<TElement>> _indexes = [];
|
||||
|
||||
public SortedIndexData<TElement> GetOrCreateIndex(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_indexes.TryGetValue(key, out var index))
|
||||
{
|
||||
index = new SortedIndexData<TElement>();
|
||||
_indexes[key] = index;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetIndex(TKey key, out SortedIndexData<TElement>? index)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _indexes.TryGetValue(key, out index);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveIndex(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _indexes.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_indexes.TryGetValue(key, out var index))
|
||||
{
|
||||
index.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single sorted index.
|
||||
/// </summary>
|
||||
public sealed class SortedIndexData<TElement> where TElement : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly SortedList<double, List<TElement>> _byScore = [];
|
||||
private readonly Dictionary<TElement, double> _elementScores = [];
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public bool Add(TElement element, double score)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var isNew = true;
|
||||
|
||||
// Remove existing entry if present
|
||||
if (_elementScores.TryGetValue(element, out var oldScore))
|
||||
{
|
||||
isNew = false;
|
||||
if (_byScore.TryGetValue(oldScore, out var oldList))
|
||||
{
|
||||
oldList.Remove(element);
|
||||
if (oldList.Count == 0)
|
||||
{
|
||||
_byScore.Remove(oldScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
_elementScores[element] = score;
|
||||
if (!_byScore.TryGetValue(score, out var list))
|
||||
{
|
||||
list = [];
|
||||
_byScore[score] = list;
|
||||
}
|
||||
list.Add(element);
|
||||
|
||||
return isNew;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_elementScores.TryGetValue(element, out var score))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_elementScores.Remove(element);
|
||||
if (_byScore.TryGetValue(score, out var list))
|
||||
{
|
||||
list.Remove(element);
|
||||
if (list.Count == 0)
|
||||
{
|
||||
_byScore.Remove(score);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public long RemoveByScoreRange(double minScore, double maxScore)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var toRemove = _byScore
|
||||
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
|
||||
.SelectMany(kvp => kvp.Value)
|
||||
.ToList();
|
||||
|
||||
foreach (var element in toRemove)
|
||||
{
|
||||
Remove(element);
|
||||
}
|
||||
|
||||
return toRemove.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public double? GetScore(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elementScores.TryGetValue(element, out var score) ? score : null;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<(TElement Element, double Score)> GetByRank(long start, long stop, bool ascending)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var all = ascending
|
||||
? _byScore.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList()
|
||||
: _byScore.Reverse().SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList();
|
||||
|
||||
var count = all.Count;
|
||||
if (start < 0) start = Math.Max(0, count + start);
|
||||
if (stop < 0) stop = count + stop;
|
||||
stop = Math.Min(stop, count - 1);
|
||||
|
||||
if (start > stop || start >= count)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return all.Skip((int)start).Take((int)(stop - start + 1)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<(TElement Element, double Score)> GetByScoreRange(double minScore, double maxScore, bool ascending, int? limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var filtered = _byScore
|
||||
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
|
||||
.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key)));
|
||||
|
||||
if (!ascending)
|
||||
{
|
||||
filtered = filtered.Reverse();
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
return filtered.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elementScores.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set store data with multiple sets.
|
||||
/// </summary>
|
||||
public sealed class SetStoreData<TKey, TElement> where TKey : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<TKey, SetData<TElement>> _sets = [];
|
||||
|
||||
public SetData<TElement> GetOrCreateSet(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sets.TryGetValue(key, out var set))
|
||||
{
|
||||
set = new SetData<TElement>();
|
||||
_sets[key] = set;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetSet(TKey key, out SetData<TElement>? set)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _sets.TryGetValue(key, out set);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveSet(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _sets.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_sets.TryGetValue(key, out var set))
|
||||
{
|
||||
set.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single set.
|
||||
/// </summary>
|
||||
public sealed class SetData<TElement>
|
||||
{
|
||||
private readonly HashSet<TElement> _elements = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public bool Add(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
public long AddRange(IEnumerable<TElement> elements)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
long added = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (_elements.Add(element))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Contains(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Contains(element);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Remove(element);
|
||||
}
|
||||
}
|
||||
|
||||
public long RemoveRange(IEnumerable<TElement> elements)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
long removed = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (_elements.Remove(element))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlySet<TElement> GetAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return new HashSet<TElement>(_elements);
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event stream storage with ordered entries.
|
||||
/// </summary>
|
||||
public sealed class EventStreamStore<TEvent> where TEvent : class
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<EventStreamEntry<TEvent>> _entries = [];
|
||||
private readonly Channel<EventStreamEntry<TEvent>> _channel;
|
||||
private long _nextSequence = 1;
|
||||
|
||||
public EventStreamStore()
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<EventStreamEntry<TEvent>>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
}
|
||||
|
||||
public string Add(TEvent @event, string? tenantId, string? correlationId, IReadOnlyDictionary<string, string>? headers, DateTimeOffset timestamp)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sequence = _nextSequence++;
|
||||
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{sequence}";
|
||||
var entry = new EventStreamEntry<TEvent>
|
||||
{
|
||||
EntryId = entryId,
|
||||
Sequence = sequence,
|
||||
Event = @event,
|
||||
Timestamp = timestamp,
|
||||
TenantId = tenantId,
|
||||
CorrelationId = correlationId,
|
||||
Headers = headers
|
||||
};
|
||||
_entries.Add(entry);
|
||||
|
||||
// Notify subscribers
|
||||
_channel.Writer.TryWrite(entry);
|
||||
|
||||
return entryId;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<EventStreamEntry<TEvent>> GetEntriesAfter(string? afterEntryId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(afterEntryId) || afterEntryId == "0")
|
||||
{
|
||||
return _entries.ToList();
|
||||
}
|
||||
|
||||
var startIndex = _entries.FindIndex(e => e.EntryId == afterEntryId);
|
||||
if (startIndex < 0)
|
||||
{
|
||||
return _entries.ToList();
|
||||
}
|
||||
|
||||
return _entries.Skip(startIndex + 1).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public ChannelReader<EventStreamEntry<TEvent>> Reader => _channel.Reader;
|
||||
|
||||
public (long Length, string? FirstEntryId, string? LastEntryId, DateTimeOffset? FirstTimestamp, DateTimeOffset? LastTimestamp) GetInfo()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
{
|
||||
return (0, null, null, null, null);
|
||||
}
|
||||
|
||||
return (
|
||||
_entries.Count,
|
||||
_entries[0].EntryId,
|
||||
_entries[^1].EntryId,
|
||||
_entries[0].Timestamp,
|
||||
_entries[^1].Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public long Trim(long maxLength)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count <= maxLength)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var toRemove = (int)(_entries.Count - maxLength);
|
||||
_entries.RemoveRange(0, toRemove);
|
||||
return toRemove;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in an event stream.
|
||||
/// </summary>
|
||||
public sealed class EventStreamEntry<TEvent> where TEvent : class
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required long Sequence { get; init; }
|
||||
public required TEvent Event { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry stored in an in-memory queue.
|
||||
/// </summary>
|
||||
public sealed class InMemoryQueueEntry<TMessage> where TMessage : class
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required TMessage Message { get; init; }
|
||||
public required int Attempt { get; set; }
|
||||
public required DateTimeOffset EnqueuedAt { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? IdempotencyKey { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
// Lease tracking
|
||||
public string? LeasedBy { get; set; }
|
||||
public DateTimeOffset? LeaseExpiresAt { get; set; }
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm for rate limiting.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRateLimiter(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var currentCount = bucket.GetCount(windowStart);
|
||||
|
||||
if (currentCount >= policy.MaxPermits)
|
||||
{
|
||||
// Denied - calculate retry after
|
||||
var retryAfter = policy.Window; // Simplified - actual implementation could track exact timestamps
|
||||
return ValueTask.FromResult(RateLimitResult.Denied(currentCount, retryAfter));
|
||||
}
|
||||
|
||||
// Increment and allow
|
||||
var newCount = bucket.Increment(now, windowStart);
|
||||
var remaining = Math.Max(0, policy.MaxPermits - newCount);
|
||||
return ValueTask.FromResult(RateLimitResult.Allowed(newCount, remaining));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var currentCount = bucket.GetCount(windowStart);
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return ValueTask.FromResult(new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window, // Simplified
|
||||
Exists = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.RemoveRateLimitBucket(fullKey));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRateLimiterFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryRateLimiter(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Provides unordered set operations.
|
||||
/// </summary>
|
||||
public sealed class InMemorySetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly SetStoreData<TKey, TElement> _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySetStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
_store = registry.GetOrCreateSetStore<TKey, TElement>(name);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var set = _store.GetOrCreateSet(setKey);
|
||||
return ValueTask.FromResult(set.Add(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var set = _store.GetOrCreateSet(setKey);
|
||||
return ValueTask.FromResult(set.AddRange(elements));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlySet<TElement>>(new HashSet<TElement>());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.GetAll());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Contains(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Remove(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.RemoveRange(elements));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.RemoveSet(setKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Count());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
_store.SetExpiration(setKey, expiresAt);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory set store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemorySetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySetStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name) where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemorySetStore<TKey, TElement>(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Provides score-ordered collections with range queries.
|
||||
/// </summary>
|
||||
public sealed class InMemorySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly SortedIndexStore<TKey, TElement> _store;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySortedIndex(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
_store = registry.GetOrCreateSortedIndex<TKey, TElement>(name);
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var index = _store.GetOrCreateIndex(indexKey);
|
||||
var wasAdded = index.Add(element, score);
|
||||
return ValueTask.FromResult(wasAdded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var index = _store.GetOrCreateIndex(indexKey);
|
||||
long addedCount = 0;
|
||||
foreach (var item in elements)
|
||||
{
|
||||
if (index.Add(item.Element, item.Score))
|
||||
{
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(addedCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
|
||||
}
|
||||
|
||||
var results = index.GetByRank(start, stop, order == SortOrder.Ascending);
|
||||
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
|
||||
}
|
||||
|
||||
var results = index.GetByScoreRange(minScore, maxScore, order == SortOrder.Ascending, limit);
|
||||
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<double?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.GetScore(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.Remove(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
long removed = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (index.Remove(element))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.RemoveByScoreRange(minScore, maxScore));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.Count());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.RemoveIndex(indexKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
_store.SetExpiration(indexKey, expiresAt);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class InMemorySortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySortedIndexFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemorySortedIndex<TKey, TElement>(_registry, name, null, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory transport plugin for StellaOps.Messaging.
|
||||
/// Useful for testing and development scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register shared registry (singleton for test state sharing)
|
||||
context.Services.AddSingleton<InMemoryQueueRegistry>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, InMemoryRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, InMemoryAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, InMemorySortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, InMemorySetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, InMemoryEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, InMemoryIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<InMemoryTransportPlugin>()
|
||||
.LogDebug("Registered in-memory transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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.Messaging.Transport.InMemory</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.InMemory</AssemblyName>
|
||||
<Description>In-memory transport plugin for StellaOps.Messaging (for testing)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the PostgreSQL transport.
|
||||
/// </summary>
|
||||
public class PostgresTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the connection string.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConnectionString { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the schema name for queue tables.
|
||||
/// </summary>
|
||||
public string Schema { get; set; } = "messaging";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-create tables on startup.
|
||||
/// </summary>
|
||||
public bool AutoCreateTables { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command timeout in seconds.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Uses DELETE ... RETURNING for atomic token consumption.
|
||||
/// </summary>
|
||||
public sealed class PostgresAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresAtomicTokenStore<TPayload>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresAtomicTokenStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresAtomicTokenStore<TPayload>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.atomic_token_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, token, payload, issued_at, expires_at)
|
||||
VALUES (@Key, @Token, @Payload::jsonb, @IssuedAt, @ExpiresAt)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
payload = EXCLUDED.payload,
|
||||
issued_at = EXCLUDED.issued_at,
|
||||
expires_at = EXCLUDED.expires_at";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
Key = key,
|
||||
Token = token,
|
||||
Payload = payloadJson,
|
||||
IssuedAt = now.UtcDateTime,
|
||||
ExpiresAt = expiresAt.UtcDateTime
|
||||
}, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, token, payload, issued_at, expires_at)
|
||||
VALUES (@Key, @Token, @Payload::jsonb, @IssuedAt, @ExpiresAt)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
payload = EXCLUDED.payload,
|
||||
issued_at = EXCLUDED.issued_at,
|
||||
expires_at = EXCLUDED.expires_at";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
Key = key,
|
||||
Token = token,
|
||||
Payload = payloadJson,
|
||||
IssuedAt = now.UtcDateTime,
|
||||
ExpiresAt = expiresAt.UtcDateTime
|
||||
}, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// First, get the entry to check expiration and mismatch
|
||||
var selectSql = $@"SELECT token, payload, issued_at, expires_at FROM {TableName} WHERE key = @Key";
|
||||
var entry = await conn.QuerySingleOrDefaultAsync<TokenRow>(
|
||||
new CommandDefinition(selectSql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var issuedAt = new DateTimeOffset(entry.IssuedAt, TimeSpan.Zero);
|
||||
var expiresAt = new DateTimeOffset(entry.ExpiresAt, TimeSpan.Zero);
|
||||
|
||||
if (expiresAt < now)
|
||||
{
|
||||
// Delete expired entry
|
||||
await conn.ExecuteAsync(new CommandDefinition(
|
||||
$"DELETE FROM {TableName} WHERE key = @Key", new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
return TokenConsumeResult<TPayload>.Expired(issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
}
|
||||
|
||||
// Atomic delete with condition
|
||||
var deleteSql = $@"
|
||||
DELETE FROM {TableName}
|
||||
WHERE key = @Key AND token = @Token
|
||||
RETURNING payload";
|
||||
|
||||
var deletedPayload = await conn.ExecuteScalarAsync<string>(
|
||||
new CommandDefinition(deleteSql, new { Key = key, Token = expectedToken }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (deletedPayload is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Deserialize<TPayload>(deletedPayload, _jsonOptions);
|
||||
return TokenConsumeResult<TPayload>.Success(payload!, issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE key = @Key AND expires_at > @Now)";
|
||||
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{_name}_expires ON {TableName} (expires_at);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private sealed class TokenRow
|
||||
{
|
||||
public string Token { get; init; } = null!;
|
||||
public string Payload { get; init; } = null!;
|
||||
public DateTime IssuedAt { get; init; }
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresAtomicTokenStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresAtomicTokenStore<TPayload>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresAtomicTokenStore<TPayload>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresCacheFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresCacheStore<TKey, TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresCacheStore<TKey, TValue>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresCacheStore<TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresCacheStore<TValue>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class PostgresCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly CacheOptions _cacheOptions;
|
||||
private readonly ILogger<PostgresCacheStore<TKey, TValue>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _tableName;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private volatile bool _tableInitialized;
|
||||
|
||||
public PostgresCacheStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<PostgresCacheStore<TKey, TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var cacheName = (_cacheOptions.KeyPrefix ?? "default").Replace(":", "_").Replace("-", "_").ToLowerInvariant();
|
||||
_tableName = $"{_connectionFactory.Schema}.cache_{cacheName}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT value, expires_at FROM {_tableName}
|
||||
WHERE key = @Key AND (expires_at IS NULL OR expires_at > @Now)
|
||||
LIMIT 1;";
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { Key = cacheKey, Now = now.UtcDateTime }).ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
|
||||
// Handle sliding expiration
|
||||
if (_cacheOptions.SlidingExpiration && _cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
var updateSql = $"UPDATE {_tableName} SET expires_at = @ExpiresAt WHERE key = @Key;";
|
||||
await conn.ExecuteAsync(updateSql, new
|
||||
{
|
||||
Key = cacheKey,
|
||||
ExpiresAt = now.Add(_cacheOptions.DefaultTtl.Value).UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<TValue>((string)row.value, _jsonOptions);
|
||||
return value is not null ? CacheResult<TValue>.Found(value) : CacheResult<TValue>.Miss();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
|
||||
DateTime? expiresAt = null;
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiresAt = now.Add(options.TimeToLive.Value).UtcDateTime;
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiresAt = options.AbsoluteExpiration.Value.UtcDateTime;
|
||||
}
|
||||
else if (_cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
expiresAt = now.Add(_cacheOptions.DefaultTtl.Value).UtcDateTime;
|
||||
}
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {_tableName} (key, value, expires_at, created_at, updated_at)
|
||||
VALUES (@Key, @Value, @ExpiresAt, @Now, @Now)
|
||||
ON CONFLICT (key) DO UPDATE SET value = @Value, expires_at = @ExpiresAt, updated_at = @Now;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Key = cacheKey,
|
||||
Value = serialized,
|
||||
ExpiresAt = expiresAt,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE key = @Key;";
|
||||
var affected = await conn.ExecuteAsync(sql, new { Key = cacheKey }).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert glob pattern to SQL LIKE pattern
|
||||
var likePattern = (_cacheOptions.KeyPrefix ?? "") + pattern.Replace("*", "%");
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE key LIKE @Pattern;";
|
||||
return await conn.ExecuteAsync(sql, new { Pattern = likePattern }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_cacheOptions.KeyPrefix}{keyString}";
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conn.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {_connectionFactory.Schema};").ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {_tableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_tableName.Replace(".", "_")}_expires
|
||||
ON {_tableName} (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
";
|
||||
|
||||
await conn.ExecuteAsync(sql).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed PostgreSQL cache store.
|
||||
/// </summary>
|
||||
public sealed class PostgresCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly PostgresCacheStore<string, TValue> _inner;
|
||||
|
||||
public PostgresCacheStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
CacheOptions options,
|
||||
ILogger<PostgresCacheStore<TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new PostgresCacheStore<string, TValue>(connectionFactory, options, null, jsonOptions, key => key, timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL connections.
|
||||
/// </summary>
|
||||
public sealed class PostgresConnectionFactory : IAsyncDisposable
|
||||
{
|
||||
private readonly PostgresTransportOptions _options;
|
||||
private readonly ILogger<PostgresConnectionFactory>? _logger;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private bool _disposed;
|
||||
|
||||
public PostgresConnectionFactory(
|
||||
IOptions<PostgresTransportOptions> options,
|
||||
ILogger<PostgresConnectionFactory>? logger = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(_options.ConnectionString);
|
||||
_dataSource = builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name.
|
||||
/// </summary>
|
||||
public string Schema => _options.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command timeout.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds => _options.CommandTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new connection.
|
||||
/// </summary>
|
||||
public async ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection.
|
||||
/// </summary>
|
||||
public async ValueTask PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Uses polling-based subscription with optional LISTEN/NOTIFY.
|
||||
/// </summary>
|
||||
public sealed class PostgresEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly ILogger<PostgresEventStream<TEvent>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresEventStream(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
EventStreamOptions options,
|
||||
ILogger<PostgresEventStream<TEvent>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.event_stream_{_options.StreamName.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var eventJson = JsonSerializer.Serialize(@event, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (data, tenant_id, correlation_id, timestamp)
|
||||
VALUES (@Data::jsonb, @TenantId, @CorrelationId, @Timestamp)
|
||||
RETURNING id";
|
||||
|
||||
var id = await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new
|
||||
{
|
||||
Data = eventJson,
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
Timestamp = now.UtcDateTime
|
||||
}, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
await TrimInternalAsync(conn, _options.MaxLength.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var entryId = $"{now.ToUnixTimeMilliseconds()}-{id}";
|
||||
return EventPublishResult.Succeeded(entryId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var result = await PublishAsync(@event, options, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
long lastId = position.Value switch
|
||||
{
|
||||
"0" => 0,
|
||||
"$" => long.MaxValue, // Will be resolved to actual max
|
||||
_ => ParseEntryId(position.Value)
|
||||
};
|
||||
|
||||
// If starting from end, get current max ID
|
||||
if (position.Value == "$")
|
||||
{
|
||||
await using var initConn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var maxIdSql = $@"SELECT COALESCE(MAX(id), 0) FROM {TableName}";
|
||||
lastId = await initConn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(maxIdSql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT id, data, tenant_id, correlation_id, timestamp
|
||||
FROM {TableName}
|
||||
WHERE id > @LastId
|
||||
ORDER BY id
|
||||
LIMIT 100";
|
||||
|
||||
var entries = await conn.QueryAsync<EventRow>(
|
||||
new CommandDefinition(sql, new { LastId = lastId }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entriesList = entries.ToList();
|
||||
|
||||
if (entriesList.Count > 0)
|
||||
{
|
||||
foreach (var entry in entriesList)
|
||||
{
|
||||
var @event = JsonSerializer.Deserialize<TEvent>(entry.Data, _jsonOptions);
|
||||
if (@event is not null)
|
||||
{
|
||||
var timestamp = new DateTimeOffset(entry.Timestamp, TimeSpan.Zero);
|
||||
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{entry.Id}";
|
||||
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entryId,
|
||||
@event,
|
||||
timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
}
|
||||
lastId = entry.Id;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No new entries, wait before polling again
|
||||
await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT
|
||||
COUNT(*) as length,
|
||||
MIN(id) as first_id,
|
||||
MAX(id) as last_id,
|
||||
MIN(timestamp) as first_ts,
|
||||
MAX(timestamp) as last_ts
|
||||
FROM {TableName}";
|
||||
|
||||
var info = await conn.QuerySingleAsync<StreamInfoRow>(
|
||||
new CommandDefinition(sql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? firstEntryId = null;
|
||||
string? lastEntryId = null;
|
||||
DateTimeOffset? firstTs = null;
|
||||
DateTimeOffset? lastTs = null;
|
||||
|
||||
if (info.FirstId.HasValue && info.FirstTs.HasValue)
|
||||
{
|
||||
firstTs = new DateTimeOffset(info.FirstTs.Value, TimeSpan.Zero);
|
||||
firstEntryId = $"{firstTs.Value.ToUnixTimeMilliseconds()}-{info.FirstId.Value}";
|
||||
}
|
||||
|
||||
if (info.LastId.HasValue && info.LastTs.HasValue)
|
||||
{
|
||||
lastTs = new DateTimeOffset(info.LastTs.Value, TimeSpan.Zero);
|
||||
lastEntryId = $"{lastTs.Value.ToUnixTimeMilliseconds()}-{info.LastId.Value}";
|
||||
}
|
||||
|
||||
return new StreamInfo(info.Length, firstEntryId, lastEntryId, firstTs, lastTs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await TrimInternalAsync(conn, maxLength, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<long> TrimInternalAsync(Npgsql.NpgsqlConnection conn, long maxLength, CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = $@"
|
||||
WITH to_delete AS (
|
||||
SELECT id FROM {TableName}
|
||||
ORDER BY id DESC
|
||||
OFFSET @MaxLength
|
||||
)
|
||||
DELETE FROM {TableName}
|
||||
WHERE id IN (SELECT id FROM to_delete)";
|
||||
|
||||
return await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { MaxLength = maxLength }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _options.StreamName.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
data JSONB NOT NULL,
|
||||
tenant_id TEXT,
|
||||
correlation_id TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_timestamp ON {TableName} (timestamp);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private static long ParseEntryId(string entryId)
|
||||
{
|
||||
// Format is "timestamp-id"
|
||||
var dashIndex = entryId.LastIndexOf('-');
|
||||
if (dashIndex > 0 && long.TryParse(entryId.AsSpan(dashIndex + 1), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class EventRow
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Data { get; init; } = null!;
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
private sealed class StreamInfoRow
|
||||
{
|
||||
public long Length { get; init; }
|
||||
public long? FirstId { get; init; }
|
||||
public long? LastId { get; init; }
|
||||
public DateTime? FirstTs { get; init; }
|
||||
public DateTime? LastTs { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL event stream instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresEventStreamFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresEventStream<TEvent>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresEventStream<TEvent>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Uses INSERT ... ON CONFLICT DO NOTHING for atomic claiming.
|
||||
/// </summary>
|
||||
public sealed class PostgresIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresIdempotencyStore>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresIdempotencyStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresIdempotencyStore>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.idempotency_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(window);
|
||||
|
||||
// Clean up expired entries first
|
||||
var cleanupSql = $@"DELETE FROM {TableName} WHERE expires_at < @Now";
|
||||
await conn.ExecuteAsync(new CommandDefinition(cleanupSql, new { Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Try to insert
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, value, expires_at)
|
||||
VALUES (@Key, @Value, @ExpiresAt)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = key, Value = value, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
return IdempotencyResult.Claimed();
|
||||
}
|
||||
|
||||
// Key already exists, get existing value
|
||||
var existingSql = $@"SELECT value FROM {TableName} WHERE key = @Key";
|
||||
var existingValue = await conn.ExecuteScalarAsync<string?>(
|
||||
new CommandDefinition(existingSql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return IdempotencyResult.Duplicate(existingValue ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE key = @Key AND expires_at > @Now)";
|
||||
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql = $@"SELECT value FROM {TableName} WHERE key = @Key AND expires_at > @Now";
|
||||
|
||||
return await conn.ExecuteScalarAsync<string?>(
|
||||
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {TableName}
|
||||
SET expires_at = expires_at + @Extension
|
||||
WHERE key = @Key";
|
||||
|
||||
var updated = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key, Extension = extension }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _name.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresIdempotencyStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresIdempotencyStore(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresIdempotencyStore>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class PostgresMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly PostgresMessageQueue<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal PostgresMessageLease(
|
||||
PostgresMessageQueue<TMessage> queue,
|
||||
string messageId,
|
||||
TMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? tenantId,
|
||||
string? correlationId)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = messageId;
|
||||
Message = message;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
TenantId = tenantId;
|
||||
CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, extension, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IMessageQueue{TMessage}"/> using FOR UPDATE SKIP LOCKED.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class PostgresMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly MessageQueueOptions _queueOptions;
|
||||
private readonly ILogger<PostgresMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly string _tableName;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private volatile bool _tableInitialized;
|
||||
|
||||
public PostgresMessageQueue(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
MessageQueueOptions queueOptions,
|
||||
ILogger<PostgresMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
// Sanitize queue name for table name
|
||||
var sanitizedName = queueOptions.QueueName.Replace(":", "_").Replace("-", "_").ToLowerInvariant();
|
||||
_tableName = $"{_connectionFactory.Schema}.queue_{sanitizedName}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _queueOptions.QueueName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var payload = JsonSerializer.Serialize(message, _jsonOptions);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check idempotency if key provided
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var existingSql = $@"
|
||||
SELECT id FROM {_tableName}
|
||||
WHERE idempotency_key = @IdempotencyKey
|
||||
AND enqueued_at > @WindowStart
|
||||
LIMIT 1;";
|
||||
|
||||
var existing = await conn.QuerySingleOrDefaultAsync<string>(existingSql, new
|
||||
{
|
||||
IdempotencyKey = options.IdempotencyKey,
|
||||
WindowStart = now.Subtract(_queueOptions.IdempotencyWindow)
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger?.LogDebug("Duplicate enqueue detected for queue {Queue} with key {Key}", _queueOptions.QueueName, options.IdempotencyKey);
|
||||
return EnqueueResult.Duplicate(existing);
|
||||
}
|
||||
}
|
||||
|
||||
var visibleAt = options?.VisibleAt ?? now;
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {_tableName} (
|
||||
id, payload, status, attempt_count, enqueued_at, available_at,
|
||||
tenant_id, correlation_id, idempotency_key, priority
|
||||
) VALUES (
|
||||
@Id, @Payload, 'pending', 1, @EnqueuedAt, @AvailableAt,
|
||||
@TenantId, @CorrelationId, @IdempotencyKey, @Priority
|
||||
);";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = messageId,
|
||||
Payload = payload,
|
||||
EnqueuedAt = now.UtcDateTime,
|
||||
AvailableAt = visibleAt.UtcDateTime,
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
IdempotencyKey = options?.IdempotencyKey,
|
||||
Priority = options?.Priority ?? 0
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _queueOptions.QueueName);
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use FOR UPDATE SKIP LOCKED to atomically claim messages
|
||||
var sql = $@"
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM {_tableName}
|
||||
WHERE status IN ('pending', 'retrying')
|
||||
AND available_at <= @Now
|
||||
ORDER BY priority DESC, available_at ASC, enqueued_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @BatchSize
|
||||
)
|
||||
UPDATE {_tableName} q
|
||||
SET status = 'processing',
|
||||
lease_owner = @Consumer,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
updated_at = @Now
|
||||
FROM candidates c
|
||||
WHERE q.id = c.id
|
||||
RETURNING q.*;";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new
|
||||
{
|
||||
Now = now.UtcDateTime,
|
||||
BatchSize = request.BatchSize,
|
||||
Consumer = consumer,
|
||||
LeaseExpires = leaseExpires.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var lease = TryMapLease(row, consumer, leaseExpires);
|
||||
if (lease is not null)
|
||||
{
|
||||
leases.Add(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var minIdleThreshold = now.Subtract(request.MinIdleTime);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Claim messages with expired leases
|
||||
var sql = $@"
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM {_tableName}
|
||||
WHERE status = 'processing'
|
||||
AND lease_expires_at < @MinIdleThreshold
|
||||
AND attempt_count >= @MinAttempts
|
||||
ORDER BY lease_expires_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @BatchSize
|
||||
)
|
||||
UPDATE {_tableName} q
|
||||
SET lease_owner = @Consumer,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
updated_at = @Now
|
||||
FROM candidates c
|
||||
WHERE q.id = c.id
|
||||
RETURNING q.*;";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new
|
||||
{
|
||||
MinIdleThreshold = minIdleThreshold.UtcDateTime,
|
||||
MinAttempts = request.MinDeliveryAttempts,
|
||||
BatchSize = request.BatchSize,
|
||||
Consumer = consumer,
|
||||
LeaseExpires = leaseExpires.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var lease = TryMapLease(row, consumer, leaseExpires);
|
||||
if (lease is not null)
|
||||
{
|
||||
leases.Add(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"SELECT COUNT(*) FROM {_tableName} WHERE status IN ('pending', 'retrying', 'processing');";
|
||||
return await conn.ExecuteScalarAsync<long>(sql).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async ValueTask AcknowledgeAsync(PostgresMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id AND lease_owner = @Consumer;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId, Consumer = lease.Consumer }).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _queueOptions.QueueName);
|
||||
}
|
||||
|
||||
internal async ValueTask RenewLeaseAsync(PostgresMessageLease<TMessage> lease, TimeSpan extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newExpiry = now.Add(extension);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET lease_expires_at = @LeaseExpires, updated_at = @Now
|
||||
WHERE id = @Id AND lease_owner = @Consumer;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
Consumer = lease.Consumer,
|
||||
LeaseExpires = newExpiry.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
lease.RefreshLease(newExpiry);
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
PostgresMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
var backoff = CalculateBackoff(lease.Attempt + 1);
|
||||
var availableAt = now.Add(backoff);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET status = 'retrying',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
available_at = @AvailableAt,
|
||||
updated_at = @Now
|
||||
WHERE id = @Id;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
AvailableAt = availableAt.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Released message {MessageId} for retry, attempt {Attempt}", lease.MessageId, lease.Attempt + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Abandon - just delete
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(PostgresMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_queueOptions.DeadLetterQueue))
|
||||
{
|
||||
// Move to dead-letter status (or separate table)
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET status = 'dead_letter',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
last_error = @Reason,
|
||||
updated_at = @Now
|
||||
WHERE id = @Id;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
Reason = reason,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId }).ConfigureAwait(false);
|
||||
_logger?.LogWarning("Dropped message {MessageId} (no DLQ): {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
if (attempt <= 1) return _queueOptions.RetryInitialBackoff;
|
||||
|
||||
var initial = _queueOptions.RetryInitialBackoff;
|
||||
var max = _queueOptions.RetryMaxBackoff;
|
||||
var multiplier = _queueOptions.RetryBackoffMultiplier;
|
||||
|
||||
var scaledTicks = initial.Ticks * Math.Pow(multiplier, attempt - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
|
||||
return TimeSpan.FromTicks((long)Math.Max(initial.Ticks, cappedTicks));
|
||||
}
|
||||
|
||||
private PostgresMessageLease<TMessage>? TryMapLease(dynamic row, string consumer, DateTimeOffset leaseExpires)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = (string)row.payload;
|
||||
var message = JsonSerializer.Deserialize<TMessage>(payload, _jsonOptions);
|
||||
if (message is null) return null;
|
||||
|
||||
return new PostgresMessageLease<TMessage>(
|
||||
this,
|
||||
(string)row.id,
|
||||
message,
|
||||
(int)row.attempt_count,
|
||||
new DateTimeOffset(DateTime.SpecifyKind((DateTime)row.enqueued_at, DateTimeKind.Utc)),
|
||||
leaseExpires,
|
||||
consumer,
|
||||
(string?)row.tenant_id,
|
||||
(string?)row.correlation_id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create schema if not exists
|
||||
await conn.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {_connectionFactory.Schema};").ConfigureAwait(false);
|
||||
|
||||
// Create table
|
||||
var createTableSql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {_tableName} (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 1,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL,
|
||||
available_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
lease_owner TEXT,
|
||||
lease_expires_at TIMESTAMPTZ,
|
||||
tenant_id TEXT,
|
||||
correlation_id TEXT,
|
||||
idempotency_key TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_status_available
|
||||
ON {_tableName} (status, available_at, priority DESC)
|
||||
WHERE status IN ('pending', 'retrying');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_lease
|
||||
ON {_tableName} (lease_expires_at)
|
||||
WHERE status = 'processing';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_idempotency
|
||||
ON {_tableName} (idempotency_key, enqueued_at)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
";
|
||||
|
||||
await conn.ExecuteAsync(createTableSql).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
_logger?.LogDebug("Initialized queue table {Table}", _tableName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL message queue instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresMessageQueueFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new PostgresMessageQueue<TMessage>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresMessageQueue<TMessage>>(),
|
||||
_timeProvider,
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm with INSERT ON CONFLICT UPDATE.
|
||||
/// </summary>
|
||||
public sealed class PostgresRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresRateLimiter>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresRateLimiter(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresRateLimiter>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.rate_limit_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
// Clean up old entries and count + increment
|
||||
var sql = $@"
|
||||
WITH cleaned AS (
|
||||
DELETE FROM {TableName}
|
||||
WHERE key = @Key AND timestamp < @WindowStart
|
||||
),
|
||||
existing AS (
|
||||
SELECT COUNT(*) as cnt FROM {TableName} WHERE key = @Key
|
||||
),
|
||||
inserted AS (
|
||||
INSERT INTO {TableName} (key, timestamp)
|
||||
VALUES (@Key, @Now)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT (SELECT cnt FROM existing) + 1 as count";
|
||||
|
||||
var currentCount = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, new { Key = key, WindowStart = windowStart.UtcDateTime, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (currentCount > policy.MaxPermits)
|
||||
{
|
||||
var retryAfter = policy.Window;
|
||||
return RateLimitResult.Denied(currentCount, retryAfter);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
return RateLimitResult.Allowed(currentCount, remaining);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE key = @Key AND timestamp >= @WindowStart";
|
||||
var currentCount = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, new { Key = key, WindowStart = windowStart.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window,
|
||||
Exists = currentCount > 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{_name}_key_ts ON {TableName} (key, timestamp);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresRateLimiterFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresRateLimiter(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresRateLimiter>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Uses unique constraint for set semantics.
|
||||
/// </summary>
|
||||
public sealed class PostgresSetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresSetStore<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresSetStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresSetStore<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.set_store_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (set_key, element, element_hash)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element))
|
||||
ON CONFLICT (set_key, element_hash) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
long addedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = Serialize(element);
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (set_key, element, element_hash)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element))
|
||||
ON CONFLICT (set_key, element_hash) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == true) addedCount++;
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"SELECT element FROM {TableName} WHERE set_key = @Key";
|
||||
var results = await conn.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var set = new HashSet<TElement>();
|
||||
foreach (var json in results)
|
||||
{
|
||||
var element = Deserialize(json);
|
||||
if (element is not null)
|
||||
{
|
||||
set.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element))";
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
long removedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = Serialize(element);
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
removedCount += deleted;
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE set_key = @Key";
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
|
||||
var sql = $@"UPDATE {TableName} SET expires_at = @ExpiresAt WHERE set_key = @Key";
|
||||
await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _name.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
set_key TEXT NOT NULL,
|
||||
element JSONB NOT NULL,
|
||||
element_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE (set_key, element_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_set_key ON {TableName} (set_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at) WHERE expires_at IS NOT NULL;";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string Serialize(TElement element)
|
||||
{
|
||||
if (element is string s) return JsonSerializer.Serialize(s, _jsonOptions);
|
||||
return JsonSerializer.Serialize(element, _jsonOptions);
|
||||
}
|
||||
|
||||
private TElement? Deserialize(string value)
|
||||
{
|
||||
if (typeof(TElement) == typeof(string))
|
||||
{
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL set store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresSetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresSetStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresSetStore<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresSetStore<TKey, TElement>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Uses B-tree indexes for efficient score-based queries.
|
||||
/// </summary>
|
||||
public sealed class PostgresSortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresSortedIndex<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresSortedIndex(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresSortedIndex<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.sorted_index_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (index_key, element, element_hash, score)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element), @Score)
|
||||
ON CONFLICT (index_key, element_hash) DO UPDATE SET score = EXCLUDED.score
|
||||
RETURNING (xmax = 0)";
|
||||
|
||||
var wasInserted = await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson, Score = score }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return wasInserted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
long addedCount = 0;
|
||||
|
||||
foreach (var item in elementsList)
|
||||
{
|
||||
var elementJson = JsonSerializer.Serialize(item.Element, _jsonOptions);
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (index_key, element, element_hash, score)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element), @Score)
|
||||
ON CONFLICT (index_key, element_hash) DO UPDATE SET score = EXCLUDED.score
|
||||
RETURNING (xmax = 0)";
|
||||
|
||||
var wasInserted = await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson, Score = item.Score }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (wasInserted) addedCount++;
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var orderSql = order == SortOrder.Ascending ? "ASC" : "DESC";
|
||||
|
||||
// Handle negative indices like Redis
|
||||
var sql = $@"
|
||||
WITH ranked AS (
|
||||
SELECT element, score, ROW_NUMBER() OVER (ORDER BY score {orderSql}) - 1 AS rank
|
||||
FROM {TableName}
|
||||
WHERE index_key = @Key
|
||||
)
|
||||
SELECT element, score FROM ranked
|
||||
WHERE rank >= @Start AND rank <= @Stop";
|
||||
|
||||
var results = await conn.QueryAsync<ElementScoreRow>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Start = start, Stop = stop }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Select(r => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>(r.Element, _jsonOptions)!,
|
||||
r.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var orderSql = order == SortOrder.Ascending ? "ASC" : "DESC";
|
||||
var limitSql = limit.HasValue ? $"LIMIT {limit.Value}" : "";
|
||||
|
||||
var sql = $@"
|
||||
SELECT element, score FROM {TableName}
|
||||
WHERE index_key = @Key AND score >= @MinScore AND score <= @MaxScore
|
||||
ORDER BY score {orderSql}
|
||||
{limitSql}";
|
||||
|
||||
var results = await conn.QueryAsync<ElementScoreRow>(
|
||||
new CommandDefinition(sql, new { Key = keyString, MinScore = minScore, MaxScore = maxScore }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Select(r => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>(r.Element, _jsonOptions)!,
|
||||
r.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"SELECT score FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
return await conn.ExecuteScalarAsync<double?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
long removedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
removedCount += deleted;
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND score >= @MinScore AND score <= @MaxScore";
|
||||
return await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, MinScore = minScore, MaxScore = maxScore }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE index_key = @Key";
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
|
||||
var sql = $@"UPDATE {TableName} SET expires_at = @ExpiresAt WHERE index_key = @Key";
|
||||
await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _name.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
index_key TEXT NOT NULL,
|
||||
element JSONB NOT NULL,
|
||||
element_hash TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE (index_key, element_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_score ON {TableName} (index_key, score);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at) WHERE expires_at IS NOT NULL;";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private sealed class ElementScoreRow
|
||||
{
|
||||
public string Element { get; init; } = null!;
|
||||
public double Score { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresSortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresSortedIndexFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresSortedIndex<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresSortedIndex<TKey, TElement>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL transport plugin for StellaOps.Messaging.
|
||||
/// Uses FOR UPDATE SKIP LOCKED for reliable queue semantics.
|
||||
/// Ideal for air-gap deployments with PostgreSQL-only infrastructure.
|
||||
/// </summary>
|
||||
public sealed class PostgresTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register options
|
||||
context.Services.AddOptions<PostgresTransportOptions>()
|
||||
.Bind(context.GetTransportConfiguration())
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register connection factory
|
||||
context.Services.AddSingleton<PostgresConnectionFactory>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, PostgresMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, PostgresCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, PostgresRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, PostgresAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, PostgresSortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, PostgresSetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, PostgresEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, PostgresIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<PostgresTransportPlugin>()
|
||||
.LogDebug("Registered PostgreSQL transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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.Messaging.Transport.Postgres</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.Postgres</AssemblyName>
|
||||
<Description>PostgreSQL transport plugin for StellaOps.Messaging (FOR UPDATE SKIP LOCKED pattern)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" 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" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user