< Summary

Information
Class: AsiBackbone.Storage.InMemory.Outbox.InMemoryGovernanceOutboxStore
Assembly: AsiBackbone.Storage.InMemory
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Storage.InMemory/Outbox/InMemoryGovernanceOutboxStore.cs
Line coverage
95%
Covered lines: 166
Uncovered lines: 8
Coverable lines: 174
Total lines: 476
Line coverage: 95.4%
Branch coverage
73%
Covered branches: 57
Total branches: 78
Branch coverage: 73%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Storage.InMemory/Outbox/InMemoryGovernanceOutboxStore.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using AsiBackbone.Core.Emissions;
 3using AsiBackbone.Core.Outbox;
 4
 5namespace AsiBackbone.Storage.InMemory.Outbox;
 6
 7/// <summary>
 8/// In-memory governance outbox store for tests, samples, and development hosts.
 9/// </summary>
 10/// <remarks>
 11/// This store is not durable across process restarts. Production hosts should use a durable provider such as EF Core or
 12/// Same-entry status transitions use single-process compare-and-swap updates so tests and local validation do not accid
 13/// </remarks>
 14public sealed class InMemoryGovernanceOutboxStore : IAsiBackboneGovernanceOutboxClaimStore
 15{
 10616    private readonly ConcurrentDictionary<string, GovernanceOutboxEntry> entries = new(StringComparer.Ordinal);
 17
 18    /// <inheritdoc />
 19    public ValueTask<GovernanceOutboxEntry> EnqueueAsync(
 20        GovernanceEmissionEnvelope envelope,
 21        CancellationToken cancellationToken = default)
 22    {
 10023        ArgumentNullException.ThrowIfNull(envelope);
 10024        cancellationToken.ThrowIfCancellationRequested();
 25
 10026        var entry = GovernanceOutboxEntry.Create(envelope);
 27
 10028        return !entries.TryAdd(entry.OutboxEntryId, entry)
 10029            ? throw new InvalidOperationException($"Outbox entry '{entry.OutboxEntryId}' already exists.")
 10030            : ValueTask.FromResult(entry);
 31    }
 32
 33    /// <inheritdoc />
 34    public ValueTask<GovernanceOutboxEntry> SaveAsync(
 35        GovernanceOutboxEntry entry,
 36        CancellationToken cancellationToken = default)
 37    {
 1238        ArgumentNullException.ThrowIfNull(entry);
 1239        cancellationToken.ThrowIfCancellationRequested();
 40
 1241        return ValueTask.FromResult(SaveEntry(entry, cancellationToken));
 42    }
 43
 44    /// <inheritdoc />
 45    public ValueTask<GovernanceOutboxEntry?> FindByOutboxEntryIdAsync(
 46        string outboxEntryId,
 47        CancellationToken cancellationToken = default)
 48    {
 2449        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 2450        cancellationToken.ThrowIfCancellationRequested();
 51
 2452        _ = entries.TryGetValue(outboxEntryId.Trim(), out GovernanceOutboxEntry? entry);
 53
 2454        return ValueTask.FromResult(entry);
 55    }
 56
 57    /// <inheritdoc />
 58    public ValueTask<IReadOnlyList<GovernanceOutboxEntry>> FindPendingAsync(
 59        int maxCount = 100,
 60        CancellationToken cancellationToken = default)
 61    {
 4062        cancellationToken.ThrowIfCancellationRequested();
 63
 4064        int normalizedMaxCount = NormalizeMaxCount(maxCount);
 65
 4066        IReadOnlyList<GovernanceOutboxEntry> matches = [.. entries.Values
 4267            .Where(entry => entry.Status is GovernanceEmissionStatus.Pending)
 3268            .OrderBy(entry => entry.CreatedUtc)
 3269            .ThenBy(entry => entry.OutboxEntryId)
 4070            .Take(normalizedMaxCount)];
 71
 4072        return ValueTask.FromResult(matches);
 73    }
 74
 75    /// <inheritdoc />
 76    public ValueTask<IReadOnlyList<GovernanceOutboxEntry>> FindRetryReadyAsync(
 77        DateTimeOffset utcNow,
 78        int maxCount = 100,
 79        CancellationToken cancellationToken = default)
 80    {
 4281        cancellationToken.ThrowIfCancellationRequested();
 82
 4283        int normalizedMaxCount = NormalizeMaxCount(maxCount);
 4284        DateTimeOffset normalizedUtcNow = utcNow.ToUniversalTime();
 85
 4286        IReadOnlyList<GovernanceOutboxEntry> matches = [.. entries.Values
 4287            .Where(entry => entry.IsRetryReady(normalizedUtcNow))
 888            .OrderBy(entry => entry.NextRetryUtc ?? entry.UpdatedUtc)
 889            .ThenBy(entry => entry.OutboxEntryId)
 4290            .Take(normalizedMaxCount)];
 91
 4292        return ValueTask.FromResult(matches);
 93    }
 94
 95    /// <inheritdoc />
 96    public ValueTask<IReadOnlyList<GovernanceOutboxClaim>> ClaimPendingAsync(
 97        GovernanceOutboxClaimRequest request,
 98        CancellationToken cancellationToken = default)
 99    {
 60100        ArgumentNullException.ThrowIfNull(request);
 60101        cancellationToken.ThrowIfCancellationRequested();
 102
 60103        IReadOnlyList<GovernanceOutboxEntry> candidates = [.. entries.Values
 58104            .Where(entry => entry.Status is GovernanceEmissionStatus.Pending)
 52105            .Where(entry => entry.CanBeClaimed(request.UtcNow))
 48106            .OrderBy(entry => entry.CreatedUtc)
 108107            .ThenBy(entry => entry.OutboxEntryId)];
 108
 60109        return ValueTask.FromResult<IReadOnlyList<GovernanceOutboxClaim>>(ClaimEntries(candidates, request, cancellation
 110    }
 111
 112    /// <inheritdoc />
 113    public ValueTask<IReadOnlyList<GovernanceOutboxClaim>> ClaimRetryReadyAsync(
 114        GovernanceOutboxClaimRequest request,
 115        CancellationToken cancellationToken = default)
 116    {
 34117        ArgumentNullException.ThrowIfNull(request);
 34118        cancellationToken.ThrowIfCancellationRequested();
 119
 34120        IReadOnlyList<GovernanceOutboxEntry> candidates = [.. entries.Values
 30121            .Where(entry => entry.IsRetryReady(request.UtcNow))
 6122            .Where(entry => entry.CanBeClaimed(request.UtcNow))
 6123            .OrderBy(entry => entry.NextRetryUtc ?? entry.UpdatedUtc)
 40124            .ThenBy(entry => entry.OutboxEntryId)];
 125
 34126        return ValueTask.FromResult<IReadOnlyList<GovernanceOutboxClaim>>(ClaimEntries(candidates, request, cancellation
 127    }
 128
 129    /// <inheritdoc />
 130    public ValueTask<GovernanceOutboxEntry> MarkDeliveredAsync(
 131        string outboxEntryId,
 132        GovernanceEmissionResult result,
 133        CancellationToken cancellationToken = default)
 134    {
 14135        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 14136        ArgumentNullException.ThrowIfNull(result);
 14137        cancellationToken.ThrowIfCancellationRequested();
 138
 14139        GovernanceOutboxEntry updatedEntry = UpdateExistingEntry(
 14140            outboxEntryId,
 14141            entry => IsTerminal(entry) ? entry : entry.MarkDelivered(result),
 14142            cancellationToken);
 143
 14144        return ValueTask.FromResult(updatedEntry);
 145    }
 146
 147    /// <inheritdoc />
 148    public ValueTask<GovernanceOutboxEntry> MarkClaimDeliveredAsync(
 149        GovernanceOutboxClaim claim,
 150        GovernanceEmissionResult result,
 151        CancellationToken cancellationToken = default)
 152    {
 16153        ArgumentNullException.ThrowIfNull(claim);
 16154        ArgumentNullException.ThrowIfNull(result);
 16155        cancellationToken.ThrowIfCancellationRequested();
 156
 16157        GovernanceOutboxEntry updatedEntry = UpdateClaimedEntry(
 16158            claim,
 14159            entry => IsTerminal(entry) ? entry : entry.MarkDelivered(result),
 16160            cancellationToken);
 161
 16162        return ValueTask.FromResult(updatedEntry);
 163    }
 164
 165    /// <inheritdoc />
 166    public ValueTask<GovernanceOutboxEntry> MarkFailedAsync(
 167        string outboxEntryId,
 168        GovernanceEmissionError governanceEmissionError,
 169        DateTimeOffset? nextRetryUtc = null,
 170        CancellationToken cancellationToken = default)
 171    {
 34172        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 34173        ArgumentNullException.ThrowIfNull(governanceEmissionError);
 34174        cancellationToken.ThrowIfCancellationRequested();
 175
 34176        GovernanceOutboxEntry updatedEntry = UpdateExistingEntry(
 34177            outboxEntryId,
 34178            entry => IsTerminal(entry) ? entry : entry.MarkFailed(governanceEmissionError, nextRetryUtc),
 34179            cancellationToken);
 180
 34181        return ValueTask.FromResult(updatedEntry);
 182    }
 183
 184    /// <inheritdoc />
 185    public ValueTask<GovernanceOutboxEntry> MarkClaimFailedAsync(
 186        GovernanceOutboxClaim claim,
 187        GovernanceEmissionError governanceEmissionError,
 188        DateTimeOffset? nextRetryUtc = null,
 189        CancellationToken cancellationToken = default)
 190    {
 10191        ArgumentNullException.ThrowIfNull(claim);
 10192        ArgumentNullException.ThrowIfNull(governanceEmissionError);
 10193        cancellationToken.ThrowIfCancellationRequested();
 194
 10195        GovernanceOutboxEntry updatedEntry = UpdateClaimedEntry(
 10196            claim,
 10197            entry => IsTerminal(entry) ? entry : entry.MarkFailed(governanceEmissionError, nextRetryUtc),
 10198            cancellationToken);
 199
 10200        return ValueTask.FromResult(updatedEntry);
 201    }
 202
 203    /// <inheritdoc />
 204    public ValueTask<GovernanceOutboxEntry> MarkDeadLetteredAsync(
 205        string outboxEntryId,
 206        GovernanceEmissionError governanceEmissionError,
 207        string? deadLetterReason = null,
 208        CancellationToken cancellationToken = default)
 209    {
 6210        ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId);
 6211        ArgumentNullException.ThrowIfNull(governanceEmissionError);
 6212        cancellationToken.ThrowIfCancellationRequested();
 213
 6214        GovernanceOutboxEntry updatedEntry = UpdateExistingEntry(
 6215            outboxEntryId,
 6216            entry => IsTerminal(entry) ? entry : entry.MarkDeadLettered(governanceEmissionError, deadLetterReason),
 6217            cancellationToken);
 218
 6219        return ValueTask.FromResult(updatedEntry);
 220    }
 221
 222    /// <inheritdoc />
 223    public ValueTask<GovernanceOutboxEntry> MarkClaimDeadLetteredAsync(
 224        GovernanceOutboxClaim claim,
 225        GovernanceEmissionError governanceEmissionError,
 226        string? deadLetterReason = null,
 227        CancellationToken cancellationToken = default)
 228    {
 6229        ArgumentNullException.ThrowIfNull(claim);
 6230        ArgumentNullException.ThrowIfNull(governanceEmissionError);
 6231        cancellationToken.ThrowIfCancellationRequested();
 232
 6233        GovernanceOutboxEntry updatedEntry = UpdateClaimedEntry(
 6234            claim,
 6235            entry => IsTerminal(entry) ? entry : entry.MarkDeadLettered(governanceEmissionError, deadLetterReason),
 6236            cancellationToken);
 237
 6238        return ValueTask.FromResult(updatedEntry);
 239    }
 240
 241    /// <inheritdoc />
 242    public ValueTask<GovernanceOutboxEntry> SaveClaimAsync(
 243        GovernanceOutboxClaim claim,
 244        GovernanceOutboxEntry entry,
 245        CancellationToken cancellationToken = default)
 246    {
 8247        ArgumentNullException.ThrowIfNull(claim);
 8248        ArgumentNullException.ThrowIfNull(entry);
 8249        cancellationToken.ThrowIfCancellationRequested();
 250
 8251        if (!string.Equals(claim.OutboxEntryId, entry.OutboxEntryId, StringComparison.Ordinal))
 252        {
 0253            throw new ArgumentException("Claim and entry must reference the same outbox entry ID.", nameof(entry));
 254        }
 255
 8256        GovernanceOutboxEntry updatedEntry = UpdateClaimedEntry(
 8257            claim,
 8258            currentEntry => IsTerminal(currentEntry) ? currentEntry : entry,
 8259            cancellationToken);
 260
 8261        return ValueTask.FromResult(updatedEntry);
 262    }
 263
 264    /// <inheritdoc />
 265    public ValueTask<GovernanceOutboxEntry?> ReleaseClaimAsync(
 266        GovernanceOutboxClaim claim,
 267        string? reason = null,
 268        CancellationToken cancellationToken = default)
 269    {
 20270        ArgumentNullException.ThrowIfNull(claim);
 18271        cancellationToken.ThrowIfCancellationRequested();
 272
 16273        GovernanceOutboxEntry? releasedEntry = ReleaseClaim(claim, cancellationToken);
 274
 16275        return ValueTask.FromResult(releasedEntry);
 276    }
 277
 278    private List<GovernanceOutboxClaim> ClaimEntries(
 279        IReadOnlyList<GovernanceOutboxEntry> candidates,
 280        GovernanceOutboxClaimRequest request,
 281        CancellationToken cancellationToken)
 282    {
 94283        List<GovernanceOutboxClaim> claims = new(Math.Min(request.MaxCount, candidates.Count));
 284
 294285        foreach (GovernanceOutboxEntry candidate in candidates)
 286        {
 54287            cancellationToken.ThrowIfCancellationRequested();
 288
 54289            if (claims.Count >= request.MaxCount)
 290            {
 2291                break;
 292            }
 293
 52294            GovernanceOutboxClaim? claim = TryClaimEntry(candidate.OutboxEntryId, request, cancellationToken);
 52295            if (claim is not null)
 296            {
 52297                claims.Add(claim);
 298            }
 299        }
 300
 94301        return claims;
 302    }
 303
 304    private GovernanceOutboxClaim? TryClaimEntry(
 305        string outboxEntryId,
 306        GovernanceOutboxClaimRequest request,
 307        CancellationToken cancellationToken)
 308    {
 309        while (true)
 310        {
 52311            cancellationToken.ThrowIfCancellationRequested();
 312
 52313            if (!entries.TryGetValue(outboxEntryId, out GovernanceOutboxEntry? currentEntry))
 314            {
 0315                return null;
 316            }
 317
 52318            if (!currentEntry.CanBeClaimed(request.UtcNow))
 319            {
 0320                return null;
 321            }
 322
 52323            GovernanceOutboxEntry claimedEntry = currentEntry.MarkClaimed(
 52324                request.WorkerId,
 52325                claimedUtc: request.UtcNow,
 52326                leaseDuration: request.LeaseDuration);
 327
 52328            if (entries.TryUpdate(outboxEntryId, claimedEntry, currentEntry))
 329            {
 52330                return CreateClaim(claimedEntry);
 331            }
 332        }
 333    }
 334
 335    private GovernanceOutboxEntry SaveEntry(
 336        GovernanceOutboxEntry entry,
 337        CancellationToken cancellationToken)
 338    {
 339        while (true)
 340        {
 12341            cancellationToken.ThrowIfCancellationRequested();
 342
 12343            if (!entries.TryGetValue(entry.OutboxEntryId, out GovernanceOutboxEntry? currentEntry))
 344            {
 0345                if (entries.TryAdd(entry.OutboxEntryId, entry))
 346                {
 0347                    return entry;
 348                }
 349
 350                continue;
 351            }
 352
 12353            if (IsTerminal(currentEntry))
 354            {
 2355                return currentEntry;
 356            }
 357
 10358            if (entries.TryUpdate(entry.OutboxEntryId, entry, currentEntry))
 359            {
 10360                return entry;
 361            }
 362        }
 363    }
 364
 365    private GovernanceOutboxEntry UpdateExistingEntry(
 366        string outboxEntryId,
 367        Func<GovernanceOutboxEntry, GovernanceOutboxEntry> updateEntry,
 368        CancellationToken cancellationToken)
 369    {
 54370        string normalizedOutboxEntryId = outboxEntryId.Trim();
 371
 372        while (true)
 373        {
 54374            cancellationToken.ThrowIfCancellationRequested();
 375
 54376            if (!entries.TryGetValue(normalizedOutboxEntryId, out GovernanceOutboxEntry? currentEntry))
 377            {
 0378                throw new InvalidOperationException($"Outbox entry '{normalizedOutboxEntryId}' was not found.");
 379            }
 380
 54381            GovernanceOutboxEntry updatedEntry = updateEntry(currentEntry);
 382
 54383            if (ReferenceEquals(currentEntry, updatedEntry))
 384            {
 2385                return currentEntry;
 386            }
 387
 52388            if (entries.TryUpdate(normalizedOutboxEntryId, updatedEntry, currentEntry))
 389            {
 52390                return updatedEntry;
 391            }
 392        }
 393    }
 394
 395    private GovernanceOutboxEntry UpdateClaimedEntry(
 396        GovernanceOutboxClaim claim,
 397        Func<GovernanceOutboxEntry, GovernanceOutboxEntry> updateEntry,
 398        CancellationToken cancellationToken)
 399    {
 400        while (true)
 401        {
 40402            cancellationToken.ThrowIfCancellationRequested();
 403
 40404            if (!entries.TryGetValue(claim.OutboxEntryId, out GovernanceOutboxEntry? currentEntry))
 405            {
 0406                throw new InvalidOperationException($"Outbox entry '{claim.OutboxEntryId}' was not found.");
 407            }
 408
 40409            if (!currentEntry.IsClaimedBy(claim) || IsTerminal(currentEntry))
 410            {
 2411                return currentEntry;
 412            }
 413
 38414            GovernanceOutboxEntry updatedEntry = updateEntry(currentEntry);
 415
 38416            if (ReferenceEquals(currentEntry, updatedEntry))
 417            {
 0418                return currentEntry;
 419            }
 420
 38421            if (entries.TryUpdate(claim.OutboxEntryId, updatedEntry, currentEntry))
 422            {
 38423                return updatedEntry;
 424            }
 425        }
 426    }
 427
 428    private GovernanceOutboxEntry? ReleaseClaim(
 429        GovernanceOutboxClaim claim,
 430        CancellationToken cancellationToken)
 431    {
 432        while (true)
 433        {
 16434            cancellationToken.ThrowIfCancellationRequested();
 435
 16436            if (!entries.TryGetValue(claim.OutboxEntryId, out GovernanceOutboxEntry? currentEntry))
 437            {
 2438                return null;
 439            }
 440
 14441            if (!currentEntry.IsClaimedBy(claim) || IsTerminal(currentEntry))
 442            {
 10443                return currentEntry;
 444            }
 445
 4446            GovernanceOutboxEntry releasedEntry = currentEntry.ReleaseClaim();
 447
 4448            if (entries.TryUpdate(claim.OutboxEntryId, releasedEntry, currentEntry))
 449            {
 4450                return releasedEntry;
 451            }
 452        }
 453    }
 454
 455    private static GovernanceOutboxClaim CreateClaim(GovernanceOutboxEntry entry)
 456    {
 52457        return GovernanceOutboxClaim.Create(
 52458            entry,
 52459            entry.ClaimOwner ?? throw new InvalidOperationException("Claimed entry is missing claim owner."),
 52460            entry.ClaimToken ?? throw new InvalidOperationException("Claimed entry is missing claim token."),
 52461            entry.ClaimedUtc ?? throw new InvalidOperationException("Claimed entry is missing claimed timestamp."),
 52462            entry.ClaimExpiresUtc ?? throw new InvalidOperationException("Claimed entry is missing claim expiration time
 463    }
 464
 465    private static bool IsTerminal(GovernanceOutboxEntry entry)
 466    {
 146467        return entry.IsDelivered || entry.IsDeadLettered;
 468    }
 469
 470    private static int NormalizeMaxCount(int maxCount)
 471    {
 82472        return maxCount <= 0
 82473            ? throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "Maximum count must be greater than zero
 82474            : maxCount;
 475    }
 476}

Methods/Properties

.ctor()
EnqueueAsync(AsiBackbone.Core.Emissions.GovernanceEmissionEnvelope,System.Threading.CancellationToken)
SaveAsync(AsiBackbone.Core.Outbox.GovernanceOutboxEntry,System.Threading.CancellationToken)
FindByOutboxEntryIdAsync(System.String,System.Threading.CancellationToken)
FindPendingAsync(System.Int32,System.Threading.CancellationToken)
FindRetryReadyAsync(System.DateTimeOffset,System.Int32,System.Threading.CancellationToken)
ClaimPendingAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaimRequest,System.Threading.CancellationToken)
ClaimRetryReadyAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaimRequest,System.Threading.CancellationToken)
MarkDeliveredAsync(System.String,AsiBackbone.Core.Emissions.GovernanceEmissionResult,System.Threading.CancellationToken)
MarkClaimDeliveredAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,AsiBackbone.Core.Emissions.GovernanceEmissionResult,System.Threading.CancellationToken)
MarkFailedAsync(System.String,AsiBackbone.Core.Emissions.GovernanceEmissionError,System.Nullable`1<System.DateTimeOffset>,System.Threading.CancellationToken)
MarkClaimFailedAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,AsiBackbone.Core.Emissions.GovernanceEmissionError,System.Nullable`1<System.DateTimeOffset>,System.Threading.CancellationToken)
MarkDeadLetteredAsync(System.String,AsiBackbone.Core.Emissions.GovernanceEmissionError,System.String,System.Threading.CancellationToken)
MarkClaimDeadLetteredAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,AsiBackbone.Core.Emissions.GovernanceEmissionError,System.String,System.Threading.CancellationToken)
SaveClaimAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,AsiBackbone.Core.Outbox.GovernanceOutboxEntry,System.Threading.CancellationToken)
ReleaseClaimAsync(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,System.String,System.Threading.CancellationToken)
ClaimEntries(System.Collections.Generic.IReadOnlyList`1<AsiBackbone.Core.Outbox.GovernanceOutboxEntry>,AsiBackbone.Core.Outbox.GovernanceOutboxClaimRequest,System.Threading.CancellationToken)
TryClaimEntry(System.String,AsiBackbone.Core.Outbox.GovernanceOutboxClaimRequest,System.Threading.CancellationToken)
SaveEntry(AsiBackbone.Core.Outbox.GovernanceOutboxEntry,System.Threading.CancellationToken)
UpdateExistingEntry(System.String,System.Func`2<AsiBackbone.Core.Outbox.GovernanceOutboxEntry,AsiBackbone.Core.Outbox.GovernanceOutboxEntry>,System.Threading.CancellationToken)
UpdateClaimedEntry(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,System.Func`2<AsiBackbone.Core.Outbox.GovernanceOutboxEntry,AsiBackbone.Core.Outbox.GovernanceOutboxEntry>,System.Threading.CancellationToken)
ReleaseClaim(AsiBackbone.Core.Outbox.GovernanceOutboxClaim,System.Threading.CancellationToken)
CreateClaim(AsiBackbone.Core.Outbox.GovernanceOutboxEntry)
IsTerminal(AsiBackbone.Core.Outbox.GovernanceOutboxEntry)
NormalizeMaxCount(System.Int32)