< Summary

Information
Class: AsiBackbone.EntityFrameworkCore.Outbox.EfCoreGovernanceOutboxStore
Assembly: AsiBackbone.EntityFrameworkCore
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.EntityFrameworkCore/Outbox/EfCoreGovernanceOutboxStore.cs
Line coverage
97%
Covered lines: 203
Uncovered lines: 5
Coverable lines: 208
Total lines: 358
Line coverage: 97.5%
Branch coverage
89%
Covered branches: 43
Total branches: 48
Branch coverage: 89.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
EnqueueAsync()100%11100%
SaveAsync()50%2277.77%
FindByOutboxEntryIdAsync()50%22100%
FindPendingAsync()100%11100%
FindRetryReadyAsync()100%11100%
MarkDeliveredAsync()100%11100%
MarkFailedAsync()100%11100%
MarkDeadLetteredAsync()100%11100%
RequireEntryAsync()50%22100%
OutboxEntries()100%11100%
ToEntity(...)100%2626100%
ToEntries(...)100%11100%
ToEntry(...)100%66100%
DeserializeMetadata(...)83.33%6683.33%
EmptyMetadata()100%11100%
NormalizeMaxCount(...)50%22100%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.EntityFrameworkCore/Outbox/EfCoreGovernanceOutboxStore.cs

#LineLine coverage
 1using System.Collections.ObjectModel;
 2using System.Text.Json;
 3using AsiBackbone.Core.Emissions;
 4using AsiBackbone.Core.Entities;
 5using AsiBackbone.Core.Outbox;
 6using AsiBackbone.EntityFrameworkCore.Persistence;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace AsiBackbone.EntityFrameworkCore.Outbox;
 10
 11/// <summary>
 12/// Entity Framework Core-backed governance outbox store that persists provider-neutral emission envelopes through a hos
 13/// </summary>
 14/// <remarks>
 15/// This store provides durable local storage only. Provider delivery, telemetry export, SIEM routing, and cloud emissio
 16/// </remarks>
 17public sealed class EfCoreGovernanceOutboxStore : IAsiBackboneGovernanceOutboxStore
 18{
 219    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
 20
 21    private readonly DbContext dbContext;
 22
 23    /// <summary>
 24    /// Initializes a new instance of the <see cref="EfCoreGovernanceOutboxStore" /> class.
 25    /// </summary>
 26    /// <param name="dbContext">The host-owned database context.</param>
 5427    public EfCoreGovernanceOutboxStore(DbContext dbContext)
 28    {
 5429        ArgumentNullException.ThrowIfNull(dbContext);
 30
 5431        this.dbContext = dbContext;
 5432    }
 33
 34    /// <inheritdoc />
 35    public async ValueTask<GovernanceOutboxEntry> EnqueueAsync(
 36        GovernanceEmissionEnvelope envelope,
 37        CancellationToken cancellationToken = default)
 38    {
 5439        ArgumentNullException.ThrowIfNull(envelope);
 5440        cancellationToken.ThrowIfCancellationRequested();
 41
 5442        var entry = GovernanceOutboxEntry.Create(envelope);
 43
 5444        _ = await dbContext
 5445            .Set<AsiBackboneGovernanceOutboxEntryEntity>()
 5446            .AddAsync(ToEntity(entry), cancellationToken)
 5447            .ConfigureAwait(false);
 48
 5449        _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 50
 5451        return entry;
 5452    }
 53
 54    /// <inheritdoc />
 55    public async ValueTask<GovernanceOutboxEntry> SaveAsync(
 56        GovernanceOutboxEntry entry,
 57        CancellationToken cancellationToken = default)
 58    {
 2259        ArgumentNullException.ThrowIfNull(entry);
 2260        cancellationToken.ThrowIfCancellationRequested();
 61
 2262        AsiBackboneGovernanceOutboxEntryEntity persistedEntity = ToEntity(entry);
 2263        AsiBackboneGovernanceOutboxEntryEntity? existingEntity = await dbContext
 2264            .Set<AsiBackboneGovernanceOutboxEntryEntity>()
 2265            .SingleOrDefaultAsync(entity => entity.OutboxEntryId == entry.OutboxEntryId, cancellationToken)
 2266            .ConfigureAwait(false);
 67
 2268        if (existingEntity is null)
 69        {
 070            _ = await dbContext
 071                .Set<AsiBackboneGovernanceOutboxEntryEntity>()
 072                .AddAsync(persistedEntity, cancellationToken)
 073                .ConfigureAwait(false);
 74        }
 75        else
 76        {
 2277            persistedEntity.Id = existingEntity.Id;
 2278            persistedEntity.ConcurrencyStamp = AsiBackboneEntity.NewConcurrencyStamp();
 2279            dbContext.Entry(existingEntity).CurrentValues.SetValues(persistedEntity);
 80        }
 81
 2282        _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 83
 2084        return entry;
 2085    }
 86
 87    /// <inheritdoc />
 88    public async ValueTask<GovernanceOutboxEntry?> FindByOutboxEntryIdAsync(
 89        string outboxEntryId,
 90        CancellationToken cancellationToken = default)
 91    {
 2892        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 93
 2894        string normalizedOutboxEntryId = outboxEntryId.Trim();
 95
 2896        AsiBackboneGovernanceOutboxEntryEntity? entity = await OutboxEntries()
 2897            .Where(outboxEntry => outboxEntry.OutboxEntryId == normalizedOutboxEntryId)
 2898            .SingleOrDefaultAsync(cancellationToken)
 2899            .ConfigureAwait(false);
 100
 28101        return entity is null ? null : ToEntry(entity);
 28102    }
 103
 104    /// <inheritdoc />
 105    public async ValueTask<IReadOnlyList<GovernanceOutboxEntry>> FindPendingAsync(
 106        int maxCount = 100,
 107        CancellationToken cancellationToken = default)
 108    {
 12109        int normalizedMaxCount = NormalizeMaxCount(maxCount);
 110
 12111        List<AsiBackboneGovernanceOutboxEntryEntity> entities = await OutboxEntries()
 12112            .Where(outboxEntry => outboxEntry.Status == GovernanceEmissionStatus.Pending)
 12113            .ToListAsync(cancellationToken)
 12114            .ConfigureAwait(false);
 115
 12116        return [.. ToEntries(entities)
 46117            .OrderBy(entry => entry.CreatedUtc)
 46118            .ThenBy(entry => entry.OutboxEntryId, StringComparer.Ordinal)
 12119            .Take(normalizedMaxCount)];
 12120    }
 121
 122    /// <inheritdoc />
 123    public async ValueTask<IReadOnlyList<GovernanceOutboxEntry>> FindRetryReadyAsync(
 124        DateTimeOffset utcNow,
 125        int maxCount = 100,
 126        CancellationToken cancellationToken = default)
 127    {
 6128        int normalizedMaxCount = NormalizeMaxCount(maxCount);
 6129        DateTimeOffset normalizedUtcNow = utcNow.ToUniversalTime();
 130
 6131        List<AsiBackboneGovernanceOutboxEntryEntity> entities = await OutboxEntries()
 6132            .Where(outboxEntry =>
 6133                outboxEntry.Status == GovernanceEmissionStatus.Deferred ||
 6134                outboxEntry.Status == GovernanceEmissionStatus.Failed ||
 6135                outboxEntry.Status == GovernanceEmissionStatus.RetryableFailure)
 6136            .ToListAsync(cancellationToken)
 6137            .ConfigureAwait(false);
 138
 6139        return [.. ToEntries(entities)
 22140            .Where(entry => entry.IsRetryReady(normalizedUtcNow))
 14141            .OrderBy(entry => entry.NextRetryUtc ?? entry.UpdatedUtc)
 14142            .ThenBy(entry => entry.OutboxEntryId, StringComparer.Ordinal)
 6143            .Take(normalizedMaxCount)];
 6144    }
 145
 146    /// <inheritdoc />
 147    public async ValueTask<GovernanceOutboxEntry> MarkDeliveredAsync(
 148        string outboxEntryId,
 149        GovernanceEmissionResult result,
 150        CancellationToken cancellationToken = default)
 151    {
 6152        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 6153        ArgumentNullException.ThrowIfNull(result);
 154
 6155        GovernanceOutboxEntry entry = await RequireEntryAsync(outboxEntryId, cancellationToken).ConfigureAwait(false);
 6156        GovernanceOutboxEntry updatedEntry = entry.MarkDelivered(result);
 157
 6158        return await SaveAsync(updatedEntry, cancellationToken).ConfigureAwait(false);
 4159    }
 160
 161    /// <inheritdoc />
 162    public async ValueTask<GovernanceOutboxEntry> MarkFailedAsync(
 163        string outboxEntryId,
 164        GovernanceEmissionError governanceEmissionError,
 165        DateTimeOffset? nextRetryUtc = null,
 166        CancellationToken cancellationToken = default)
 167    {
 12168        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 12169        ArgumentNullException.ThrowIfNull(governanceEmissionError);
 170
 12171        GovernanceOutboxEntry entry = await RequireEntryAsync(outboxEntryId, cancellationToken).ConfigureAwait(false);
 12172        GovernanceOutboxEntry updatedEntry = entry.MarkFailed(governanceEmissionError, nextRetryUtc);
 173
 12174        return await SaveAsync(updatedEntry, cancellationToken).ConfigureAwait(false);
 12175    }
 176
 177    /// <inheritdoc />
 178    public async ValueTask<GovernanceOutboxEntry> MarkDeadLetteredAsync(
 179        string outboxEntryId,
 180        GovernanceEmissionError governanceEmissionError,
 181        string? deadLetterReason = null,
 182        CancellationToken cancellationToken = default)
 183    {
 2184        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 2185        ArgumentNullException.ThrowIfNull(governanceEmissionError);
 186
 2187        GovernanceOutboxEntry entry = await RequireEntryAsync(outboxEntryId, cancellationToken).ConfigureAwait(false);
 2188        GovernanceOutboxEntry updatedEntry = entry.MarkDeadLettered(governanceEmissionError, deadLetterReason);
 189
 2190        return await SaveAsync(updatedEntry, cancellationToken).ConfigureAwait(false);
 2191    }
 192
 193    private async ValueTask<GovernanceOutboxEntry> RequireEntryAsync(
 194        string outboxEntryId,
 195        CancellationToken cancellationToken)
 196    {
 20197        GovernanceOutboxEntry? entry = await FindByOutboxEntryIdAsync(outboxEntryId, cancellationToken).ConfigureAwait(f
 198
 20199        return entry ?? throw new InvalidOperationException($"Outbox entry '{outboxEntryId.Trim()}' was not found.");
 20200    }
 201
 202    private IQueryable<AsiBackboneGovernanceOutboxEntryEntity> OutboxEntries()
 203    {
 46204        return dbContext.Set<AsiBackboneGovernanceOutboxEntryEntity>().AsNoTracking();
 205    }
 206
 207    private static AsiBackboneGovernanceOutboxEntryEntity ToEntity(GovernanceOutboxEntry entry)
 208    {
 76209        GovernanceEmissionEnvelope envelope = entry.Envelope;
 76210        GovernanceEmissionPayload? payload = envelope.Payload;
 76211        GovernanceEmissionError? lastError = entry.LastError;
 212
 76213        return new AsiBackboneGovernanceOutboxEntryEntity
 76214        {
 76215            OutboxEntryId = entry.OutboxEntryId,
 76216            Status = entry.Status,
 76217            CreatedUtc = entry.CreatedUtc,
 76218            UpdatedUtc = entry.UpdatedUtc,
 76219            DeliveredUtc = entry.Status is GovernanceEmissionStatus.Delivered ? entry.UpdatedUtc : null,
 76220            RetryCount = entry.RetryCount,
 76221            MaxRetryCount = entry.MaxRetryCount,
 76222            NextRetryUtc = entry.NextRetryUtc,
 76223            ProviderName = entry.ProviderName,
 76224            ProviderRecordId = entry.ProviderRecordId,
 76225            DeadLetterReason = entry.DeadLetterReason,
 76226            LastErrorCode = lastError?.Code,
 76227            LastErrorMessage = lastError?.Message,
 76228            LastErrorIsRetryable = lastError?.IsRetryable,
 76229            LastErrorProviderName = lastError?.ProviderName,
 76230            LastErrorProviderErrorCode = lastError?.ProviderErrorCode,
 76231            MetadataJson = JsonSerializer.Serialize(entry.Metadata, JsonOptions),
 76232            EnvelopeId = envelope.EnvelopeId,
 76233            EnvelopeSchemaVersion = envelope.SchemaVersion,
 76234            EnvelopeEventType = envelope.EventType,
 76235            EnvelopeEventId = envelope.EventId,
 76236            EnvelopeOccurredUtc = envelope.OccurredUtc,
 76237            EnvelopeCreatedUtc = envelope.CreatedUtc,
 76238            EnvelopeCorrelationId = envelope.CorrelationId,
 76239            EnvelopeAuditResidueId = envelope.AuditResidueId,
 76240            EnvelopeLifecycleStage = envelope.LifecycleStage,
 76241            EnvelopeLifecycleStageSequence = envelope.LifecycleStageSequence,
 76242            EnvelopePolicyVersion = envelope.PolicyVersion,
 76243            EnvelopePolicyHash = envelope.PolicyHash,
 76244            EnvelopeTraceId = envelope.TraceId,
 76245            EnvelopeSpanId = envelope.SpanId,
 76246            EnvelopeParentSpanId = envelope.ParentSpanId,
 76247            EnvelopeOperationName = envelope.OperationName,
 76248            EnvelopeOutcome = envelope.Outcome,
 76249            EnvelopeActorId = envelope.ActorId,
 76250            EnvelopeEmitterStatus = envelope.EmitterStatus,
 76251            EnvelopeEmitterProvider = envelope.EmitterProvider,
 76252            EnvelopeOutboxSequence = envelope.OutboxSequence,
 76253            EnvelopeGatewayExecutionId = envelope.GatewayExecutionId,
 76254            EnvelopeDecisionStage = envelope.DecisionStage,
 76255            EnvelopeMetadataJson = JsonSerializer.Serialize(envelope.Metadata, JsonOptions),
 76256            EnvelopePayloadType = payload?.PayloadType,
 76257            EnvelopePayloadSchemaVersion = payload?.SchemaVersion,
 76258            EnvelopePayloadContentType = payload?.ContentType,
 76259            EnvelopePayloadContentHash = payload?.ContentHash,
 76260            EnvelopePayloadSizeBytes = payload?.SizeBytes,
 76261            EnvelopePayloadMetadataJson = JsonSerializer.Serialize(payload?.Metadata ?? EmptyMetadata(), JsonOptions)
 76262        };
 263    }
 264
 265    private static GovernanceOutboxEntry[] ToEntries(IEnumerable<AsiBackboneGovernanceOutboxEntryEntity> entities)
 266    {
 18267        return [.. entities.Select(ToEntry)];
 268    }
 269
 270    private static GovernanceOutboxEntry ToEntry(AsiBackboneGovernanceOutboxEntryEntity entity)
 271    {
 96272        GovernanceEmissionPayload? payload = string.IsNullOrWhiteSpace(entity.EnvelopePayloadType)
 96273            ? null
 96274            : GovernanceEmissionPayload.Create(
 96275                entity.EnvelopePayloadType,
 96276                entity.EnvelopePayloadSchemaVersion,
 96277                entity.EnvelopePayloadContentType,
 96278                entity.EnvelopePayloadContentHash,
 96279                entity.EnvelopePayloadSizeBytes,
 96280                DeserializeMetadata(entity.EnvelopePayloadMetadataJson));
 281
 96282        var envelope = GovernanceEmissionEnvelope.Create(
 96283            entity.EnvelopeEventType,
 96284            entity.EnvelopeEventId,
 96285            entity.EnvelopeOccurredUtc,
 96286            entity.EnvelopeId,
 96287            entity.EnvelopeCreatedUtc,
 96288            entity.EnvelopeSchemaVersion,
 96289            entity.EnvelopeCorrelationId,
 96290            entity.EnvelopeAuditResidueId,
 96291            entity.EnvelopeLifecycleStage,
 96292            entity.EnvelopePolicyVersion,
 96293            entity.EnvelopePolicyHash,
 96294            entity.EnvelopeTraceId,
 96295            entity.EnvelopeSpanId,
 96296            entity.EnvelopeParentSpanId,
 96297            entity.EnvelopeOperationName,
 96298            entity.EnvelopeOutcome,
 96299            entity.EnvelopeActorId,
 96300            entity.EnvelopeEmitterStatus,
 96301            entity.EnvelopeEmitterProvider,
 96302            entity.EnvelopeOutboxSequence,
 96303            entity.EnvelopeGatewayExecutionId,
 96304            entity.EnvelopeDecisionStage,
 96305            payload,
 96306            DeserializeMetadata(entity.EnvelopeMetadataJson));
 307
 96308        GovernanceEmissionError? lastError = string.IsNullOrWhiteSpace(entity.LastErrorCode) || string.IsNullOrWhiteSpac
 96309            ? null
 96310            : GovernanceEmissionError.Create(
 96311                entity.LastErrorCode,
 96312                entity.LastErrorMessage,
 96313                entity.LastErrorIsRetryable ?? false,
 96314                entity.LastErrorProviderName,
 96315                entity.LastErrorProviderErrorCode);
 316
 96317        return GovernanceOutboxEntry.Restore(
 96318            envelope,
 96319            entity.Status,
 96320            entity.OutboxEntryId,
 96321            entity.CreatedUtc,
 96322            entity.UpdatedUtc,
 96323            entity.RetryCount,
 96324            entity.MaxRetryCount,
 96325            entity.NextRetryUtc,
 96326            lastError,
 96327            entity.ProviderName,
 96328            entity.ProviderRecordId,
 96329            entity.DeadLetterReason,
 96330            DeserializeMetadata(entity.MetadataJson));
 331    }
 332
 333    private static ReadOnlyDictionary<string, string> DeserializeMetadata(string? json)
 334    {
 214335        if (string.IsNullOrWhiteSpace(json))
 336        {
 0337            return EmptyMetadata();
 338        }
 339
 214340        Dictionary<string, string>? metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions)
 341
 214342        return metadata is null || metadata.Count == 0
 214343            ? EmptyMetadata()
 214344            : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
 345    }
 346
 347    private static ReadOnlyDictionary<string, string> EmptyMetadata()
 348    {
 146349        return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
 350    }
 351
 352    private static int NormalizeMaxCount(int maxCount)
 353    {
 18354        return maxCount <= 0
 18355            ? throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "Maximum count must be greater than zero
 18356            : maxCount;
 357    }
 358}