| | | 1 | | using System.Collections.ObjectModel; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using AsiBackbone.Core.Emissions; |
| | | 4 | | using AsiBackbone.Core.Entities; |
| | | 5 | | using AsiBackbone.Core.Outbox; |
| | | 6 | | using AsiBackbone.EntityFrameworkCore.Persistence; |
| | | 7 | | using Microsoft.EntityFrameworkCore; |
| | | 8 | | |
| | | 9 | | namespace 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> |
| | | 17 | | public sealed class EfCoreGovernanceOutboxStore : IAsiBackboneGovernanceOutboxClaimStore |
| | | 18 | | { |
| | 2 | 19 | | 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> |
| | 186 | 27 | | public EfCoreGovernanceOutboxStore(DbContext dbContext) |
| | | 28 | | { |
| | 186 | 29 | | ArgumentNullException.ThrowIfNull(dbContext); |
| | | 30 | | |
| | 186 | 31 | | this.dbContext = dbContext; |
| | 186 | 32 | | } |
| | | 33 | | |
| | | 34 | | /// <inheritdoc /> |
| | | 35 | | public async ValueTask<GovernanceOutboxEntry> EnqueueAsync( |
| | | 36 | | GovernanceEmissionEnvelope envelope, |
| | | 37 | | CancellationToken cancellationToken = default) |
| | | 38 | | { |
| | 60 | 39 | | ArgumentNullException.ThrowIfNull(envelope); |
| | 60 | 40 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 41 | | |
| | 60 | 42 | | var entry = GovernanceOutboxEntry.Create(envelope); |
| | | 43 | | |
| | 60 | 44 | | _ = dbContext |
| | 60 | 45 | | .Set<AsiBackboneGovernanceOutboxEntryEntity>() |
| | 60 | 46 | | .Add(ToEntity(entry)); |
| | | 47 | | |
| | 60 | 48 | | _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); |
| | | 49 | | |
| | 60 | 50 | | return entry; |
| | 60 | 51 | | } |
| | | 52 | | |
| | | 53 | | /// <inheritdoc /> |
| | | 54 | | public async ValueTask<GovernanceOutboxEntry> SaveAsync( |
| | | 55 | | GovernanceOutboxEntry entry, |
| | | 56 | | CancellationToken cancellationToken = default) |
| | | 57 | | { |
| | 456 | 58 | | ArgumentNullException.ThrowIfNull(entry); |
| | 456 | 59 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 60 | | |
| | 456 | 61 | | AsiBackboneGovernanceOutboxEntryEntity persistedEntity = ToEntity(entry); |
| | 456 | 62 | | AsiBackboneGovernanceOutboxEntryEntity? existingEntity = await dbContext |
| | 456 | 63 | | .Set<AsiBackboneGovernanceOutboxEntryEntity>() |
| | 456 | 64 | | .SingleOrDefaultAsync(entity => entity.OutboxEntryId == entry.OutboxEntryId, cancellationToken) |
| | 456 | 65 | | .ConfigureAwait(false); |
| | | 66 | | |
| | 456 | 67 | | if (existingEntity is null) |
| | | 68 | | { |
| | 412 | 69 | | _ = dbContext |
| | 412 | 70 | | .Set<AsiBackboneGovernanceOutboxEntryEntity>() |
| | 412 | 71 | | .Add(persistedEntity); |
| | | 72 | | } |
| | | 73 | | else |
| | | 74 | | { |
| | 44 | 75 | | persistedEntity.Id = existingEntity.Id; |
| | 44 | 76 | | persistedEntity.ConcurrencyStamp = AsiBackboneEntity.NewConcurrencyStamp(); |
| | 44 | 77 | | dbContext.Entry(existingEntity).CurrentValues.SetValues(persistedEntity); |
| | | 78 | | } |
| | | 79 | | |
| | 456 | 80 | | _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); |
| | | 81 | | |
| | 448 | 82 | | return entry; |
| | 448 | 83 | | } |
| | | 84 | | |
| | | 85 | | /// <inheritdoc /> |
| | | 86 | | public async ValueTask<GovernanceOutboxEntry?> FindByOutboxEntryIdAsync( |
| | | 87 | | string outboxEntryId, |
| | | 88 | | CancellationToken cancellationToken = default) |
| | | 89 | | { |
| | 124 | 90 | | ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId); |
| | | 91 | | |
| | 124 | 92 | | string normalizedOutboxEntryId = outboxEntryId.Trim(); |
| | | 93 | | |
| | 124 | 94 | | AsiBackboneGovernanceOutboxEntryEntity? entity = await OutboxEntries() |
| | 124 | 95 | | .Where(outboxEntry => outboxEntry.OutboxEntryId == normalizedOutboxEntryId) |
| | 124 | 96 | | .SingleOrDefaultAsync(cancellationToken) |
| | 124 | 97 | | .ConfigureAwait(false); |
| | | 98 | | |
| | 124 | 99 | | return entity is null ? null : ToEntry(entity); |
| | 124 | 100 | | } |
| | | 101 | | |
| | | 102 | | /// <inheritdoc /> |
| | | 103 | | public async ValueTask<IReadOnlyList<GovernanceOutboxEntry>> FindPendingAsync( |
| | | 104 | | int maxCount = 100, |
| | | 105 | | CancellationToken cancellationToken = default) |
| | | 106 | | { |
| | 18 | 107 | | int normalizedMaxCount = NormalizeMaxCount(maxCount); |
| | | 108 | | |
| | 16 | 109 | | List<AsiBackboneGovernanceOutboxEntryEntity> entities = await OutboxEntries() |
| | 16 | 110 | | .Where(outboxEntry => outboxEntry.Status == GovernanceEmissionStatus.Pending) |
| | 16 | 111 | | .OrderBy(outboxEntry => outboxEntry.CreatedUtc) |
| | 16 | 112 | | .ThenBy(outboxEntry => outboxEntry.OutboxEntryId) |
| | 16 | 113 | | .Take(normalizedMaxCount) |
| | 16 | 114 | | .ToListAsync(cancellationToken) |
| | 16 | 115 | | .ConfigureAwait(false); |
| | | 116 | | |
| | 16 | 117 | | return ToEntries(entities); |
| | 16 | 118 | | } |
| | | 119 | | |
| | | 120 | | /// <inheritdoc /> |
| | | 121 | | public async ValueTask<IReadOnlyList<GovernanceOutboxEntry>> FindRetryReadyAsync( |
| | | 122 | | DateTimeOffset utcNow, |
| | | 123 | | int maxCount = 100, |
| | | 124 | | CancellationToken cancellationToken = default) |
| | | 125 | | { |
| | 10 | 126 | | int normalizedMaxCount = NormalizeMaxCount(maxCount); |
| | 8 | 127 | | DateTimeOffset normalizedUtcNow = utcNow.ToUniversalTime(); |
| | | 128 | | |
| | 8 | 129 | | List<AsiBackboneGovernanceOutboxEntryEntity> entities = await OutboxEntries() |
| | 8 | 130 | | .Where(outboxEntry => |
| | 8 | 131 | | outboxEntry.Status == GovernanceEmissionStatus.Deferred || |
| | 8 | 132 | | outboxEntry.Status == GovernanceEmissionStatus.Failed || |
| | 8 | 133 | | outboxEntry.Status == GovernanceEmissionStatus.RetryableFailure) |
| | 8 | 134 | | .Where(outboxEntry => outboxEntry.RetryCount < outboxEntry.MaxRetryCount) |
| | 8 | 135 | | .Where(outboxEntry => outboxEntry.NextRetryUtc == null || outboxEntry.NextRetryUtc <= normalizedUtcNow) |
| | 8 | 136 | | .OrderBy(outboxEntry => outboxEntry.NextRetryUtc ?? outboxEntry.UpdatedUtc) |
| | 8 | 137 | | .ThenBy(outboxEntry => outboxEntry.OutboxEntryId) |
| | 8 | 138 | | .Take(normalizedMaxCount) |
| | 8 | 139 | | .ToListAsync(cancellationToken) |
| | 8 | 140 | | .ConfigureAwait(false); |
| | | 141 | | |
| | 8 | 142 | | return ToEntries(entities); |
| | 8 | 143 | | } |
| | | 144 | | |
| | | 145 | | /// <inheritdoc /> |
| | | 146 | | public async ValueTask<IReadOnlyList<GovernanceOutboxClaim>> ClaimPendingAsync( |
| | | 147 | | GovernanceOutboxClaimRequest request, |
| | | 148 | | CancellationToken cancellationToken = default) |
| | | 149 | | { |
| | 60 | 150 | | ArgumentNullException.ThrowIfNull(request); |
| | 60 | 151 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 152 | | |
| | 60 | 153 | | List<string> candidateIds = await OutboxEntries() |
| | 60 | 154 | | .Where(outboxEntry => outboxEntry.Status == GovernanceEmissionStatus.Pending) |
| | 60 | 155 | | .Where(outboxEntry => outboxEntry.ClaimToken == null || outboxEntry.ClaimExpiresUtc == null || outboxEntry.C |
| | 60 | 156 | | .OrderBy(outboxEntry => outboxEntry.CreatedUtc) |
| | 60 | 157 | | .ThenBy(outboxEntry => outboxEntry.OutboxEntryId) |
| | 60 | 158 | | .Select(outboxEntry => outboxEntry.OutboxEntryId) |
| | 60 | 159 | | .Take(request.MaxCount) |
| | 60 | 160 | | .ToListAsync(cancellationToken) |
| | 60 | 161 | | .ConfigureAwait(false); |
| | | 162 | | |
| | 60 | 163 | | return await ClaimEntriesAsync(candidateIds, request, IsPendingClaimEligible, cancellationToken).ConfigureAwait( |
| | 60 | 164 | | } |
| | | 165 | | |
| | | 166 | | /// <inheritdoc /> |
| | | 167 | | public async ValueTask<IReadOnlyList<GovernanceOutboxClaim>> ClaimRetryReadyAsync( |
| | | 168 | | GovernanceOutboxClaimRequest request, |
| | | 169 | | CancellationToken cancellationToken = default) |
| | | 170 | | { |
| | 10 | 171 | | ArgumentNullException.ThrowIfNull(request); |
| | 8 | 172 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 173 | | |
| | 6 | 174 | | List<string> candidateIds = await OutboxEntries() |
| | 6 | 175 | | .Where(outboxEntry => |
| | 6 | 176 | | outboxEntry.Status == GovernanceEmissionStatus.Deferred || |
| | 6 | 177 | | outboxEntry.Status == GovernanceEmissionStatus.Failed || |
| | 6 | 178 | | outboxEntry.Status == GovernanceEmissionStatus.RetryableFailure) |
| | 6 | 179 | | .Where(outboxEntry => outboxEntry.RetryCount < outboxEntry.MaxRetryCount) |
| | 6 | 180 | | .Where(outboxEntry => outboxEntry.NextRetryUtc == null || outboxEntry.NextRetryUtc <= request.UtcNow) |
| | 6 | 181 | | .Where(outboxEntry => outboxEntry.ClaimToken == null || outboxEntry.ClaimExpiresUtc == null || outboxEntry.C |
| | 6 | 182 | | .OrderBy(outboxEntry => outboxEntry.NextRetryUtc ?? outboxEntry.UpdatedUtc) |
| | 6 | 183 | | .ThenBy(outboxEntry => outboxEntry.OutboxEntryId) |
| | 6 | 184 | | .Select(outboxEntry => outboxEntry.OutboxEntryId) |
| | 6 | 185 | | .Take(request.MaxCount) |
| | 6 | 186 | | .ToListAsync(cancellationToken) |
| | 6 | 187 | | .ConfigureAwait(false); |
| | | 188 | | |
| | 6 | 189 | | return await ClaimEntriesAsync(candidateIds, request, IsRetryReadyClaimEligible, cancellationToken).ConfigureAwa |
| | 6 | 190 | | } |
| | | 191 | | |
| | | 192 | | /// <inheritdoc /> |
| | | 193 | | public async ValueTask<GovernanceOutboxEntry> MarkDeliveredAsync( |
| | | 194 | | string outboxEntryId, |
| | | 195 | | GovernanceEmissionResult result, |
| | | 196 | | CancellationToken cancellationToken = default) |
| | | 197 | | { |
| | 12 | 198 | | ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId); |
| | 12 | 199 | | ArgumentNullException.ThrowIfNull(result); |
| | | 200 | | |
| | 12 | 201 | | GovernanceOutboxEntry entry = await RequireEntryAsync(outboxEntryId, cancellationToken).ConfigureAwait(false); |
| | 12 | 202 | | GovernanceOutboxEntry updatedEntry = entry.MarkDelivered(result); |
| | | 203 | | |
| | 12 | 204 | | return await SaveAsync(updatedEntry, cancellationToken).ConfigureAwait(false); |
| | 8 | 205 | | } |
| | | 206 | | |
| | | 207 | | /// <inheritdoc /> |
| | | 208 | | public async ValueTask<GovernanceOutboxEntry> MarkClaimDeliveredAsync( |
| | | 209 | | GovernanceOutboxClaim claim, |
| | | 210 | | GovernanceEmissionResult result, |
| | | 211 | | CancellationToken cancellationToken = default) |
| | | 212 | | { |
| | 24 | 213 | | ArgumentNullException.ThrowIfNull(claim); |
| | 22 | 214 | | ArgumentNullException.ThrowIfNull(result); |
| | | 215 | | |
| | 34 | 216 | | return await UpdateClaimedEntryAsync(claim, entry => entry.MarkDelivered(result), cancellationToken).ConfigureAw |
| | 18 | 217 | | } |
| | | 218 | | |
| | | 219 | | /// <inheritdoc /> |
| | | 220 | | public async ValueTask<GovernanceOutboxEntry> MarkFailedAsync( |
| | | 221 | | string outboxEntryId, |
| | | 222 | | GovernanceEmissionError governanceEmissionError, |
| | | 223 | | DateTimeOffset? nextRetryUtc = null, |
| | | 224 | | CancellationToken cancellationToken = default) |
| | | 225 | | { |
| | 20 | 226 | | ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId); |
| | 20 | 227 | | ArgumentNullException.ThrowIfNull(governanceEmissionError); |
| | | 228 | | |
| | 20 | 229 | | GovernanceOutboxEntry entry = await RequireEntryAsync(outboxEntryId, cancellationToken).ConfigureAwait(false); |
| | 20 | 230 | | GovernanceOutboxEntry updatedEntry = entry.MarkFailed(governanceEmissionError, nextRetryUtc); |
| | | 231 | | |
| | 20 | 232 | | return await SaveAsync(updatedEntry, cancellationToken).ConfigureAwait(false); |
| | 18 | 233 | | } |
| | | 234 | | |
| | | 235 | | /// <inheritdoc /> |
| | | 236 | | public async ValueTask<GovernanceOutboxEntry> MarkClaimFailedAsync( |
| | | 237 | | GovernanceOutboxClaim claim, |
| | | 238 | | GovernanceEmissionError governanceEmissionError, |
| | | 239 | | DateTimeOffset? nextRetryUtc = null, |
| | | 240 | | CancellationToken cancellationToken = default) |
| | | 241 | | { |
| | 18 | 242 | | ArgumentNullException.ThrowIfNull(claim); |
| | 16 | 243 | | ArgumentNullException.ThrowIfNull(governanceEmissionError); |
| | | 244 | | |
| | 14 | 245 | | return await UpdateClaimedEntryAsync( |
| | 14 | 246 | | claim, |
| | 6 | 247 | | entry => entry.MarkFailed(governanceEmissionError, nextRetryUtc), |
| | 14 | 248 | | cancellationToken) |
| | 14 | 249 | | .ConfigureAwait(false); |
| | 14 | 250 | | } |
| | | 251 | | |
| | | 252 | | /// <inheritdoc /> |
| | | 253 | | public async ValueTask<GovernanceOutboxEntry> MarkDeadLetteredAsync( |
| | | 254 | | string outboxEntryId, |
| | | 255 | | GovernanceEmissionError governanceEmissionError, |
| | | 256 | | string? deadLetterReason = null, |
| | | 257 | | CancellationToken cancellationToken = default) |
| | | 258 | | { |
| | 6 | 259 | | ArgumentException.ThrowIfNullOrWhiteSpace(outboxEntryId); |
| | 6 | 260 | | ArgumentNullException.ThrowIfNull(governanceEmissionError); |
| | | 261 | | |
| | 6 | 262 | | GovernanceOutboxEntry entry = await RequireEntryAsync(outboxEntryId, cancellationToken).ConfigureAwait(false); |
| | 6 | 263 | | GovernanceOutboxEntry updatedEntry = entry.MarkDeadLettered(governanceEmissionError, deadLetterReason); |
| | | 264 | | |
| | 6 | 265 | | return await SaveAsync(updatedEntry, cancellationToken).ConfigureAwait(false); |
| | 6 | 266 | | } |
| | | 267 | | |
| | | 268 | | /// <inheritdoc /> |
| | | 269 | | public async ValueTask<GovernanceOutboxEntry> MarkClaimDeadLetteredAsync( |
| | | 270 | | GovernanceOutboxClaim claim, |
| | | 271 | | GovernanceEmissionError governanceEmissionError, |
| | | 272 | | string? deadLetterReason = null, |
| | | 273 | | CancellationToken cancellationToken = default) |
| | | 274 | | { |
| | 10 | 275 | | ArgumentNullException.ThrowIfNull(claim); |
| | 8 | 276 | | ArgumentNullException.ThrowIfNull(governanceEmissionError); |
| | | 277 | | |
| | 6 | 278 | | return await UpdateClaimedEntryAsync( |
| | 6 | 279 | | claim, |
| | 6 | 280 | | entry => entry.MarkDeadLettered(governanceEmissionError, deadLetterReason), |
| | 6 | 281 | | cancellationToken) |
| | 6 | 282 | | .ConfigureAwait(false); |
| | 6 | 283 | | } |
| | | 284 | | |
| | | 285 | | /// <inheritdoc /> |
| | | 286 | | public async ValueTask<GovernanceOutboxEntry> SaveClaimAsync( |
| | | 287 | | GovernanceOutboxClaim claim, |
| | | 288 | | GovernanceOutboxEntry entry, |
| | | 289 | | CancellationToken cancellationToken = default) |
| | | 290 | | { |
| | 10 | 291 | | ArgumentNullException.ThrowIfNull(claim); |
| | 8 | 292 | | ArgumentNullException.ThrowIfNull(entry); |
| | | 293 | | |
| | 6 | 294 | | return !string.Equals(claim.OutboxEntryId, entry.OutboxEntryId, StringComparison.Ordinal) |
| | 6 | 295 | | ? throw new ArgumentException("Claim and entry must reference the same outbox entry ID.", nameof(entry)) |
| | 8 | 296 | | : await UpdateClaimedEntryAsync(claim, _ => entry, cancellationToken).ConfigureAwait(false); |
| | 2 | 297 | | } |
| | | 298 | | |
| | | 299 | | /// <inheritdoc /> |
| | | 300 | | public async ValueTask<GovernanceOutboxEntry?> ReleaseClaimAsync( |
| | | 301 | | GovernanceOutboxClaim claim, |
| | | 302 | | string? reason = null, |
| | | 303 | | CancellationToken cancellationToken = default) |
| | | 304 | | { |
| | 4 | 305 | | ArgumentNullException.ThrowIfNull(claim); |
| | 4 | 306 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 307 | | |
| | 4 | 308 | | AsiBackboneGovernanceOutboxEntryEntity? entity = await dbContext |
| | 4 | 309 | | .Set<AsiBackboneGovernanceOutboxEntryEntity>() |
| | 4 | 310 | | .SingleOrDefaultAsync(outboxEntry => outboxEntry.OutboxEntryId == claim.OutboxEntryId, cancellationToken) |
| | 4 | 311 | | .ConfigureAwait(false); |
| | | 312 | | |
| | 4 | 313 | | if (entity is null) |
| | | 314 | | { |
| | 0 | 315 | | return null; |
| | | 316 | | } |
| | | 317 | | |
| | 4 | 318 | | GovernanceOutboxEntry currentEntry = ToEntry(entity); |
| | 4 | 319 | | if (!currentEntry.IsClaimedBy(claim) || IsTerminal(currentEntry)) |
| | | 320 | | { |
| | 0 | 321 | | return currentEntry; |
| | | 322 | | } |
| | | 323 | | |
| | 4 | 324 | | GovernanceOutboxEntry releasedEntry = currentEntry.ReleaseClaim(); |
| | 4 | 325 | | await ApplyEntryUpdateAsync(entity, releasedEntry, cancellationToken).ConfigureAwait(false); |
| | | 326 | | |
| | 4 | 327 | | return releasedEntry; |
| | 4 | 328 | | } |
| | | 329 | | |
| | | 330 | | private async ValueTask<IReadOnlyList<GovernanceOutboxClaim>> ClaimEntriesAsync( |
| | | 331 | | List<string> candidateIds, |
| | | 332 | | GovernanceOutboxClaimRequest request, |
| | | 333 | | Func<AsiBackboneGovernanceOutboxEntryEntity, DateTimeOffset, bool> isEligible, |
| | | 334 | | CancellationToken cancellationToken) |
| | | 335 | | { |
| | 66 | 336 | | List<GovernanceOutboxClaim> claims = new(Math.Min(request.MaxCount, candidateIds.Count)); |
| | | 337 | | |
| | 904 | 338 | | foreach (string candidateId in candidateIds) |
| | | 339 | | { |
| | 386 | 340 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 341 | | |
| | 386 | 342 | | if (claims.Count >= request.MaxCount) |
| | | 343 | | { |
| | | 344 | | break; |
| | | 345 | | } |
| | | 346 | | |
| | 386 | 347 | | GovernanceOutboxClaim? claim = await TryClaimAsync(candidateId, request, isEligible, cancellationToken).Conf |
| | 386 | 348 | | if (claim is not null) |
| | | 349 | | { |
| | 384 | 350 | | claims.Add(claim); |
| | | 351 | | } |
| | | 352 | | } |
| | | 353 | | |
| | 66 | 354 | | return claims; |
| | 66 | 355 | | } |
| | | 356 | | |
| | | 357 | | private async ValueTask<GovernanceOutboxClaim?> TryClaimAsync( |
| | | 358 | | string outboxEntryId, |
| | | 359 | | GovernanceOutboxClaimRequest request, |
| | | 360 | | Func<AsiBackboneGovernanceOutboxEntryEntity, DateTimeOffset, bool> isEligible, |
| | | 361 | | CancellationToken cancellationToken) |
| | | 362 | | { |
| | 386 | 363 | | AsiBackboneGovernanceOutboxEntryEntity? entity = await dbContext |
| | 386 | 364 | | .Set<AsiBackboneGovernanceOutboxEntryEntity>() |
| | 386 | 365 | | .SingleOrDefaultAsync(outboxEntry => outboxEntry.OutboxEntryId == outboxEntryId, cancellationToken) |
| | 386 | 366 | | .ConfigureAwait(false); |
| | | 367 | | |
| | 386 | 368 | | if (entity is null || !isEligible(entity, request.UtcNow)) |
| | | 369 | | { |
| | 0 | 370 | | return null; |
| | | 371 | | } |
| | | 372 | | |
| | 386 | 373 | | GovernanceOutboxEntry currentEntry = ToEntry(entity); |
| | 386 | 374 | | if (!currentEntry.CanBeClaimed(request.UtcNow)) |
| | | 375 | | { |
| | 0 | 376 | | return null; |
| | | 377 | | } |
| | | 378 | | |
| | 386 | 379 | | GovernanceOutboxEntry claimedEntry = currentEntry.MarkClaimed( |
| | 386 | 380 | | request.WorkerId, |
| | 386 | 381 | | claimedUtc: request.UtcNow, |
| | 386 | 382 | | leaseDuration: request.LeaseDuration); |
| | | 383 | | |
| | | 384 | | try |
| | | 385 | | { |
| | 386 | 386 | | await ApplyEntryUpdateAsync(entity, claimedEntry, cancellationToken).ConfigureAwait(false); |
| | 384 | 387 | | return CreateClaim(claimedEntry); |
| | | 388 | | } |
| | | 389 | | catch (DbUpdateConcurrencyException exception) |
| | | 390 | | { |
| | 2 | 391 | | DetachEntries(exception); |
| | 2 | 392 | | return null; |
| | | 393 | | } |
| | 386 | 394 | | } |
| | | 395 | | |
| | | 396 | | private async ValueTask<GovernanceOutboxEntry> UpdateClaimedEntryAsync( |
| | | 397 | | GovernanceOutboxClaim claim, |
| | | 398 | | Func<GovernanceOutboxEntry, GovernanceOutboxEntry> updateEntry, |
| | | 399 | | CancellationToken cancellationToken) |
| | | 400 | | { |
| | 44 | 401 | | AsiBackboneGovernanceOutboxEntryEntity entity = await RequireEntityAsync(claim.OutboxEntryId, cancellationToken) |
| | 40 | 402 | | GovernanceOutboxEntry currentEntry = ToEntry(entity); |
| | | 403 | | |
| | 40 | 404 | | if (!currentEntry.IsClaimedBy(claim) || IsTerminal(currentEntry)) |
| | | 405 | | { |
| | 12 | 406 | | return currentEntry; |
| | | 407 | | } |
| | | 408 | | |
| | 28 | 409 | | GovernanceOutboxEntry updatedEntry = updateEntry(currentEntry); |
| | | 410 | | |
| | | 411 | | try |
| | | 412 | | { |
| | 28 | 413 | | await ApplyEntryUpdateAsync(entity, updatedEntry, cancellationToken).ConfigureAwait(false); |
| | 18 | 414 | | return updatedEntry; |
| | | 415 | | } |
| | | 416 | | catch (DbUpdateConcurrencyException exception) |
| | | 417 | | { |
| | 10 | 418 | | DetachEntries(exception); |
| | 10 | 419 | | GovernanceOutboxEntry? refreshedEntry = await FindByOutboxEntryIdAsync(claim.OutboxEntryId, cancellationToke |
| | 10 | 420 | | return refreshedEntry ?? currentEntry; |
| | | 421 | | } |
| | 40 | 422 | | } |
| | | 423 | | |
| | | 424 | | private async ValueTask ApplyEntryUpdateAsync( |
| | | 425 | | AsiBackboneGovernanceOutboxEntryEntity entity, |
| | | 426 | | GovernanceOutboxEntry entry, |
| | | 427 | | CancellationToken cancellationToken) |
| | | 428 | | { |
| | 418 | 429 | | AsiBackboneGovernanceOutboxEntryEntity persistedEntity = ToEntity(entry); |
| | 418 | 430 | | persistedEntity.Id = entity.Id; |
| | 418 | 431 | | persistedEntity.ConcurrencyStamp = AsiBackboneEntity.NewConcurrencyStamp(); |
| | 418 | 432 | | dbContext.Entry(entity).CurrentValues.SetValues(persistedEntity); |
| | | 433 | | |
| | 418 | 434 | | _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); |
| | 406 | 435 | | } |
| | | 436 | | |
| | | 437 | | private async ValueTask<GovernanceOutboxEntry> RequireEntryAsync( |
| | | 438 | | string outboxEntryId, |
| | | 439 | | CancellationToken cancellationToken) |
| | | 440 | | { |
| | 38 | 441 | | GovernanceOutboxEntry? entry = await FindByOutboxEntryIdAsync(outboxEntryId, cancellationToken).ConfigureAwait(f |
| | | 442 | | |
| | 38 | 443 | | return entry ?? throw new InvalidOperationException($"Outbox entry '{outboxEntryId.Trim()}' was not found."); |
| | 38 | 444 | | } |
| | | 445 | | |
| | | 446 | | private async ValueTask<AsiBackboneGovernanceOutboxEntryEntity> RequireEntityAsync( |
| | | 447 | | string outboxEntryId, |
| | | 448 | | CancellationToken cancellationToken) |
| | | 449 | | { |
| | 44 | 450 | | AsiBackboneGovernanceOutboxEntryEntity? entity = await dbContext |
| | 44 | 451 | | .Set<AsiBackboneGovernanceOutboxEntryEntity>() |
| | 44 | 452 | | .SingleOrDefaultAsync(outboxEntry => outboxEntry.OutboxEntryId == outboxEntryId.Trim(), cancellationToken) |
| | 44 | 453 | | .ConfigureAwait(false); |
| | | 454 | | |
| | 42 | 455 | | return entity ?? throw new InvalidOperationException($"Outbox entry '{outboxEntryId.Trim()}' was not found."); |
| | 40 | 456 | | } |
| | | 457 | | |
| | | 458 | | private IQueryable<AsiBackboneGovernanceOutboxEntryEntity> OutboxEntries() |
| | | 459 | | { |
| | 214 | 460 | | return dbContext.Set<AsiBackboneGovernanceOutboxEntryEntity>().AsNoTracking(); |
| | | 461 | | } |
| | | 462 | | |
| | | 463 | | private static AsiBackboneGovernanceOutboxEntryEntity ToEntity(GovernanceOutboxEntry entry) |
| | | 464 | | { |
| | 934 | 465 | | GovernanceEmissionEnvelope envelope = entry.Envelope; |
| | 934 | 466 | | GovernanceEmissionPayload? payload = envelope.Payload; |
| | 934 | 467 | | GovernanceEmissionError? lastError = entry.LastError; |
| | | 468 | | |
| | 934 | 469 | | return new AsiBackboneGovernanceOutboxEntryEntity |
| | 934 | 470 | | { |
| | 934 | 471 | | OutboxEntryId = entry.OutboxEntryId, |
| | 934 | 472 | | Status = entry.Status, |
| | 934 | 473 | | CreatedUtc = entry.CreatedUtc, |
| | 934 | 474 | | UpdatedUtc = entry.UpdatedUtc, |
| | 934 | 475 | | DeliveredUtc = entry.Status is GovernanceEmissionStatus.Delivered ? entry.UpdatedUtc : null, |
| | 934 | 476 | | RetryCount = entry.RetryCount, |
| | 934 | 477 | | MaxRetryCount = entry.MaxRetryCount, |
| | 934 | 478 | | NextRetryUtc = entry.NextRetryUtc, |
| | 934 | 479 | | ProviderName = entry.ProviderName, |
| | 934 | 480 | | ProviderRecordId = entry.ProviderRecordId, |
| | 934 | 481 | | DeadLetterReason = entry.DeadLetterReason, |
| | 934 | 482 | | LastErrorCode = lastError?.Code, |
| | 934 | 483 | | LastErrorMessage = lastError?.Message, |
| | 934 | 484 | | LastErrorIsRetryable = lastError?.IsRetryable, |
| | 934 | 485 | | LastErrorProviderName = lastError?.ProviderName, |
| | 934 | 486 | | LastErrorProviderErrorCode = lastError?.ProviderErrorCode, |
| | 934 | 487 | | MetadataJson = JsonSerializer.Serialize(entry.Metadata, JsonOptions), |
| | 934 | 488 | | ClaimOwner = entry.ClaimOwner, |
| | 934 | 489 | | ClaimToken = entry.ClaimToken, |
| | 934 | 490 | | ClaimedUtc = entry.ClaimedUtc, |
| | 934 | 491 | | ClaimExpiresUtc = entry.ClaimExpiresUtc, |
| | 934 | 492 | | ClaimAttemptCount = entry.ClaimAttemptCount, |
| | 934 | 493 | | EnvelopeId = envelope.EnvelopeId, |
| | 934 | 494 | | EnvelopeSchemaVersion = envelope.SchemaVersion, |
| | 934 | 495 | | EnvelopeEventType = envelope.EventType, |
| | 934 | 496 | | EnvelopeEventId = envelope.EventId, |
| | 934 | 497 | | EnvelopeOccurredUtc = envelope.OccurredUtc, |
| | 934 | 498 | | EnvelopeCreatedUtc = envelope.CreatedUtc, |
| | 934 | 499 | | EnvelopeCorrelationId = envelope.CorrelationId, |
| | 934 | 500 | | EnvelopeAuditResidueId = envelope.AuditResidueId, |
| | 934 | 501 | | EnvelopeLifecycleStage = envelope.LifecycleStage, |
| | 934 | 502 | | EnvelopeLifecycleStageSequence = envelope.LifecycleStageSequence, |
| | 934 | 503 | | EnvelopePolicyVersion = envelope.PolicyVersion, |
| | 934 | 504 | | EnvelopePolicyHash = envelope.PolicyHash, |
| | 934 | 505 | | EnvelopeTraceId = envelope.TraceId, |
| | 934 | 506 | | EnvelopeSpanId = envelope.SpanId, |
| | 934 | 507 | | EnvelopeParentSpanId = envelope.ParentSpanId, |
| | 934 | 508 | | EnvelopeOperationName = envelope.OperationName, |
| | 934 | 509 | | EnvelopeOutcome = envelope.Outcome, |
| | 934 | 510 | | EnvelopeActorId = envelope.ActorId, |
| | 934 | 511 | | EnvelopeEmitterStatus = envelope.EmitterStatus, |
| | 934 | 512 | | EnvelopeEmitterProvider = envelope.EmitterProvider, |
| | 934 | 513 | | EnvelopeOutboxSequence = envelope.OutboxSequence, |
| | 934 | 514 | | EnvelopeGatewayExecutionId = envelope.GatewayExecutionId, |
| | 934 | 515 | | EnvelopeDecisionStage = envelope.DecisionStage, |
| | 934 | 516 | | EnvelopeMetadataJson = JsonSerializer.Serialize(envelope.Metadata, JsonOptions), |
| | 934 | 517 | | EnvelopePayloadType = payload?.PayloadType, |
| | 934 | 518 | | EnvelopePayloadSchemaVersion = payload?.SchemaVersion, |
| | 934 | 519 | | EnvelopePayloadContentType = payload?.ContentType, |
| | 934 | 520 | | EnvelopePayloadContentHash = payload?.ContentHash, |
| | 934 | 521 | | EnvelopePayloadSizeBytes = payload?.SizeBytes, |
| | 934 | 522 | | EnvelopePayloadMetadataJson = JsonSerializer.Serialize(payload?.Metadata ?? EmptyMetadata(), JsonOptions) |
| | 934 | 523 | | }; |
| | | 524 | | } |
| | | 525 | | |
| | | 526 | | private static GovernanceOutboxEntry[] ToEntries(IEnumerable<AsiBackboneGovernanceOutboxEntryEntity> entities) |
| | | 527 | | { |
| | 24 | 528 | | return [.. entities.Select(ToEntry)]; |
| | | 529 | | } |
| | | 530 | | |
| | | 531 | | private static GovernanceOutboxEntry ToEntry(AsiBackboneGovernanceOutboxEntryEntity entity) |
| | | 532 | | { |
| | 614 | 533 | | GovernanceEmissionPayload? payload = string.IsNullOrWhiteSpace(entity.EnvelopePayloadType) |
| | 614 | 534 | | ? null |
| | 614 | 535 | | : GovernanceEmissionPayload.Create( |
| | 614 | 536 | | entity.EnvelopePayloadType, |
| | 614 | 537 | | entity.EnvelopePayloadSchemaVersion, |
| | 614 | 538 | | entity.EnvelopePayloadContentType, |
| | 614 | 539 | | entity.EnvelopePayloadContentHash, |
| | 614 | 540 | | entity.EnvelopePayloadSizeBytes, |
| | 614 | 541 | | DeserializeMetadata(entity.EnvelopePayloadMetadataJson)); |
| | | 542 | | |
| | 614 | 543 | | var envelope = GovernanceEmissionEnvelope.Create( |
| | 614 | 544 | | entity.EnvelopeEventType, |
| | 614 | 545 | | entity.EnvelopeEventId, |
| | 614 | 546 | | entity.EnvelopeOccurredUtc, |
| | 614 | 547 | | entity.EnvelopeId, |
| | 614 | 548 | | entity.EnvelopeCreatedUtc, |
| | 614 | 549 | | entity.EnvelopeSchemaVersion, |
| | 614 | 550 | | entity.EnvelopeCorrelationId, |
| | 614 | 551 | | entity.EnvelopeAuditResidueId, |
| | 614 | 552 | | entity.EnvelopeLifecycleStage, |
| | 614 | 553 | | entity.EnvelopePolicyVersion, |
| | 614 | 554 | | entity.EnvelopePolicyHash, |
| | 614 | 555 | | entity.EnvelopeTraceId, |
| | 614 | 556 | | entity.EnvelopeSpanId, |
| | 614 | 557 | | entity.EnvelopeParentSpanId, |
| | 614 | 558 | | entity.EnvelopeOperationName, |
| | 614 | 559 | | entity.EnvelopeOutcome, |
| | 614 | 560 | | entity.EnvelopeActorId, |
| | 614 | 561 | | entity.EnvelopeEmitterStatus, |
| | 614 | 562 | | entity.EnvelopeEmitterProvider, |
| | 614 | 563 | | entity.EnvelopeOutboxSequence, |
| | 614 | 564 | | entity.EnvelopeGatewayExecutionId, |
| | 614 | 565 | | entity.EnvelopeDecisionStage, |
| | 614 | 566 | | payload, |
| | 614 | 567 | | DeserializeMetadata(entity.EnvelopeMetadataJson)); |
| | | 568 | | |
| | 614 | 569 | | GovernanceEmissionError? lastError = string.IsNullOrWhiteSpace(entity.LastErrorCode) || string.IsNullOrWhiteSpac |
| | 614 | 570 | | ? null |
| | 614 | 571 | | : GovernanceEmissionError.Create( |
| | 614 | 572 | | entity.LastErrorCode, |
| | 614 | 573 | | entity.LastErrorMessage, |
| | 614 | 574 | | entity.LastErrorIsRetryable ?? false, |
| | 614 | 575 | | entity.LastErrorProviderName, |
| | 614 | 576 | | entity.LastErrorProviderErrorCode); |
| | | 577 | | |
| | 614 | 578 | | return GovernanceOutboxEntry.Restore( |
| | 614 | 579 | | envelope, |
| | 614 | 580 | | entity.Status, |
| | 614 | 581 | | entity.OutboxEntryId, |
| | 614 | 582 | | entity.CreatedUtc, |
| | 614 | 583 | | entity.UpdatedUtc, |
| | 614 | 584 | | entity.RetryCount, |
| | 614 | 585 | | entity.MaxRetryCount, |
| | 614 | 586 | | entity.NextRetryUtc, |
| | 614 | 587 | | lastError, |
| | 614 | 588 | | entity.ProviderName, |
| | 614 | 589 | | entity.ProviderRecordId, |
| | 614 | 590 | | entity.DeadLetterReason, |
| | 614 | 591 | | DeserializeMetadata(entity.MetadataJson), |
| | 614 | 592 | | entity.ClaimOwner, |
| | 614 | 593 | | entity.ClaimToken, |
| | 614 | 594 | | entity.ClaimedUtc, |
| | 614 | 595 | | entity.ClaimExpiresUtc, |
| | 614 | 596 | | entity.ClaimAttemptCount); |
| | | 597 | | } |
| | | 598 | | |
| | | 599 | | private static GovernanceOutboxClaim CreateClaim(GovernanceOutboxEntry entry) |
| | | 600 | | { |
| | 392 | 601 | | return GovernanceOutboxClaim.Create( |
| | 392 | 602 | | entry, |
| | 392 | 603 | | entry.ClaimOwner ?? throw new InvalidOperationException("Claimed entry is missing claim owner."), |
| | 392 | 604 | | entry.ClaimToken ?? throw new InvalidOperationException("Claimed entry is missing claim token."), |
| | 392 | 605 | | entry.ClaimedUtc ?? throw new InvalidOperationException("Claimed entry is missing claimed timestamp."), |
| | 392 | 606 | | entry.ClaimExpiresUtc ?? throw new InvalidOperationException("Claimed entry is missing claim expiration time |
| | | 607 | | } |
| | | 608 | | |
| | | 609 | | private static bool IsPendingClaimEligible(AsiBackboneGovernanceOutboxEntryEntity entity, DateTimeOffset utcNow) |
| | | 610 | | { |
| | 374 | 611 | | return entity.Status is GovernanceEmissionStatus.Pending && IsClaimAvailable(entity, utcNow); |
| | | 612 | | } |
| | | 613 | | |
| | | 614 | | private static bool IsRetryReadyClaimEligible(AsiBackboneGovernanceOutboxEntryEntity entity, DateTimeOffset utcNow) |
| | | 615 | | { |
| | 52 | 616 | | return (entity.Status is GovernanceEmissionStatus.Deferred or GovernanceEmissionStatus.Failed or GovernanceEmiss |
| | 52 | 617 | | && entity.RetryCount < entity.MaxRetryCount |
| | 52 | 618 | | && (entity.NextRetryUtc is null || entity.NextRetryUtc <= utcNow.ToUniversalTime()) |
| | 52 | 619 | | && IsClaimAvailable(entity, utcNow); |
| | | 620 | | } |
| | | 621 | | |
| | | 622 | | private static bool IsClaimAvailable(AsiBackboneGovernanceOutboxEntryEntity entity, DateTimeOffset utcNow) |
| | | 623 | | { |
| | 414 | 624 | | return entity.ClaimToken is null || entity.ClaimExpiresUtc is null || entity.ClaimExpiresUtc <= utcNow.ToUnivers |
| | | 625 | | } |
| | | 626 | | |
| | | 627 | | private static bool IsTerminal(GovernanceOutboxEntry entry) |
| | | 628 | | { |
| | 32 | 629 | | return entry.IsDelivered || entry.IsDeadLettered; |
| | | 630 | | } |
| | | 631 | | |
| | | 632 | | private static void DetachEntries(DbUpdateConcurrencyException exception) |
| | | 633 | | { |
| | 48 | 634 | | foreach (Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry in exception.Entries) |
| | | 635 | | { |
| | 12 | 636 | | entry.State = EntityState.Detached; |
| | | 637 | | } |
| | 12 | 638 | | } |
| | | 639 | | |
| | | 640 | | private static ReadOnlyDictionary<string, string> DeserializeMetadata(string? json) |
| | | 641 | | { |
| | 1266 | 642 | | if (string.IsNullOrWhiteSpace(json)) |
| | | 643 | | { |
| | 2 | 644 | | return EmptyMetadata(); |
| | | 645 | | } |
| | | 646 | | |
| | 1264 | 647 | | Dictionary<string, string>? metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions) |
| | | 648 | | |
| | 1264 | 649 | | return metadata is null || metadata.Count == 0 |
| | 1264 | 650 | | ? EmptyMetadata() |
| | 1264 | 651 | | : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal)); |
| | | 652 | | } |
| | | 653 | | |
| | | 654 | | private static ReadOnlyDictionary<string, string> EmptyMetadata() |
| | | 655 | | { |
| | 1848 | 656 | | return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)); |
| | | 657 | | } |
| | | 658 | | |
| | | 659 | | private static int NormalizeMaxCount(int maxCount) |
| | | 660 | | { |
| | 28 | 661 | | return maxCount <= 0 |
| | 28 | 662 | | ? throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "Maximum count must be greater than zero |
| | 28 | 663 | | : maxCount; |
| | | 664 | | } |
| | | 665 | | } |