656 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			656 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System;
 | |
| using System.Collections.Concurrent;
 | |
| using System.Collections.Generic;
 | |
| using System.Collections.ObjectModel;
 | |
| using System.Globalization;
 | |
| using System.Linq;
 | |
| using System.Threading;
 | |
| using System.Threading.Tasks;
 | |
| using Microsoft.Extensions.Logging;
 | |
| using StackExchange.Redis;
 | |
| using StellaOps.Notify.Models;
 | |
| 
 | |
| namespace StellaOps.Notify.Queue.Redis;
 | |
| 
 | |
| internal sealed class RedisNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
 | |
| {
 | |
|     private const string TransportName = "redis";
 | |
| 
 | |
|     private readonly NotifyEventQueueOptions _options;
 | |
|     private readonly NotifyRedisEventQueueOptions _redisOptions;
 | |
|     private readonly ILogger<RedisNotifyEventQueue> _logger;
 | |
|     private readonly TimeProvider _timeProvider;
 | |
|     private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
 | |
|     private readonly SemaphoreSlim _connectionLock = new(1, 1);
 | |
|     private readonly SemaphoreSlim _groupInitLock = new(1, 1);
 | |
|     private readonly IReadOnlyDictionary<string, NotifyRedisEventStreamOptions> _streamsByName;
 | |
|     private readonly ConcurrentDictionary<string, bool> _initializedStreams = new(StringComparer.Ordinal);
 | |
| 
 | |
|     private IConnectionMultiplexer? _connection;
 | |
|     private bool _disposed;
 | |
| 
 | |
|     public RedisNotifyEventQueue(
 | |
|         NotifyEventQueueOptions options,
 | |
|         NotifyRedisEventQueueOptions redisOptions,
 | |
|         ILogger<RedisNotifyEventQueue> logger,
 | |
|         TimeProvider timeProvider,
 | |
|         Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
 | |
|     {
 | |
|         _options = options ?? throw new ArgumentNullException(nameof(options));
 | |
|         _redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
 | |
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | |
|         _timeProvider = timeProvider ?? TimeProvider.System;
 | |
|         _connectionFactory = connectionFactory ?? (async config =>
 | |
|         {
 | |
|             var connection = await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false);
 | |
|             return (IConnectionMultiplexer)connection;
 | |
|         });
 | |
| 
 | |
|         if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
 | |
|         {
 | |
|             throw new InvalidOperationException("Redis connection string must be configured for Notify event queue.");
 | |
|         }
 | |
| 
 | |
|         _streamsByName = _redisOptions.Streams.ToDictionary(
 | |
|             stream => stream.Stream,
 | |
|             stream => stream,
 | |
|             StringComparer.Ordinal);
 | |
|     }
 | |
| 
 | |
|     public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
 | |
|         NotifyQueueEventMessage message,
 | |
|         CancellationToken cancellationToken = default)
 | |
|     {
 | |
|         ArgumentNullException.ThrowIfNull(message);
 | |
|         cancellationToken.ThrowIfCancellationRequested();
 | |
| 
 | |
|         var streamOptions = GetStreamOptions(message.Stream);
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|         var now = _timeProvider.GetUtcNow();
 | |
|         var entries = BuildEntries(message, now, attempt: 1);
 | |
| 
 | |
|         var messageId = await AddToStreamAsync(
 | |
|                 db,
 | |
|                 streamOptions,
 | |
|                 entries)
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         var idempotencyToken = string.IsNullOrWhiteSpace(message.IdempotencyKey)
 | |
|             ? message.Event.EventId.ToString("N")
 | |
|             : message.IdempotencyKey;
 | |
| 
 | |
|         var idempotencyKey = streamOptions.IdempotencyKeyPrefix + idempotencyToken;
 | |
|         var stored = await db.StringSetAsync(
 | |
|                 idempotencyKey,
 | |
|                 messageId,
 | |
|                 when: When.NotExists,
 | |
|                 expiry: _redisOptions.IdempotencyWindow)
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         if (!stored)
 | |
|         {
 | |
|             await db.StreamDeleteAsync(
 | |
|                     streamOptions.Stream,
 | |
|                     new RedisValue[] { messageId })
 | |
|                 .ConfigureAwait(false);
 | |
| 
 | |
|             var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
 | |
|             var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
 | |
| 
 | |
|             _logger.LogDebug(
 | |
|                 "Duplicate Notify event enqueue detected for idempotency token {Token}; returning existing stream id {StreamId}.",
 | |
|                 idempotencyToken,
 | |
|                 duplicateId.ToString());
 | |
| 
 | |
|             NotifyQueueMetrics.RecordDeduplicated(TransportName, streamOptions.Stream);
 | |
|             return new NotifyQueueEnqueueResult(duplicateId.ToString()!, true);
 | |
|         }
 | |
| 
 | |
|         NotifyQueueMetrics.RecordEnqueued(TransportName, streamOptions.Stream);
 | |
| 
 | |
|         _logger.LogDebug(
 | |
|             "Enqueued Notify event {EventId} for tenant {Tenant} on stream {Stream} (id {StreamId}).",
 | |
|             message.Event.EventId,
 | |
|             message.TenantId,
 | |
|             streamOptions.Stream,
 | |
|             messageId.ToString());
 | |
| 
 | |
|         return new NotifyQueueEnqueueResult(messageId.ToString()!, false);
 | |
|     }
 | |
| 
 | |
|     public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(
 | |
|         NotifyQueueLeaseRequest request,
 | |
|         CancellationToken cancellationToken = default)
 | |
|     {
 | |
|         ArgumentNullException.ThrowIfNull(request);
 | |
|         cancellationToken.ThrowIfCancellationRequested();
 | |
| 
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         var now = _timeProvider.GetUtcNow();
 | |
|         var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(request.BatchSize);
 | |
| 
 | |
|         foreach (var streamOptions in _streamsByName.Values)
 | |
|         {
 | |
|             await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|             var remaining = request.BatchSize - leases.Count;
 | |
|             if (remaining <= 0)
 | |
|             {
 | |
|                 break;
 | |
|             }
 | |
| 
 | |
|             var entries = await db.StreamReadGroupAsync(
 | |
|                     streamOptions.Stream,
 | |
|                     streamOptions.ConsumerGroup,
 | |
|                     request.Consumer,
 | |
|                     StreamPosition.NewMessages,
 | |
|                     remaining)
 | |
|                 .ConfigureAwait(false);
 | |
| 
 | |
|             if (entries is null || entries.Length == 0)
 | |
|             {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             foreach (var entry in entries)
 | |
|             {
 | |
|                 var lease = TryMapLease(
 | |
|                     streamOptions,
 | |
|                     entry,
 | |
|                     request.Consumer,
 | |
|                     now,
 | |
|                     request.LeaseDuration,
 | |
|                     attemptOverride: null);
 | |
| 
 | |
|                 if (lease is null)
 | |
|                 {
 | |
|                     await AckPoisonAsync(db, streamOptions, entry.Id).ConfigureAwait(false);
 | |
|                     continue;
 | |
|                 }
 | |
| 
 | |
|                 leases.Add(lease);
 | |
| 
 | |
|                 if (leases.Count >= request.BatchSize)
 | |
|                 {
 | |
|                     break;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return leases;
 | |
|     }
 | |
| 
 | |
|     public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(
 | |
|         NotifyQueueClaimOptions options,
 | |
|         CancellationToken cancellationToken = default)
 | |
|     {
 | |
|         ArgumentNullException.ThrowIfNull(options);
 | |
|         cancellationToken.ThrowIfCancellationRequested();
 | |
| 
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         var now = _timeProvider.GetUtcNow();
 | |
|         var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(options.BatchSize);
 | |
| 
 | |
|         foreach (var streamOptions in _streamsByName.Values)
 | |
|         {
 | |
|             await EnsureStreamInitializedAsync(db, streamOptions, cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|             var pending = await db.StreamPendingMessagesAsync(
 | |
|                     streamOptions.Stream,
 | |
|                     streamOptions.ConsumerGroup,
 | |
|                     options.BatchSize,
 | |
|                     RedisValue.Null,
 | |
|                     (long)options.MinIdleTime.TotalMilliseconds)
 | |
|                 .ConfigureAwait(false);
 | |
| 
 | |
|             if (pending is null || pending.Length == 0)
 | |
|             {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             var eligible = pending
 | |
|                 .Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
 | |
|                 .ToArray();
 | |
| 
 | |
|             if (eligible.Length == 0)
 | |
|             {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             var messageIds = eligible
 | |
|                 .Select(static p => (RedisValue)p.MessageId)
 | |
|                 .ToArray();
 | |
| 
 | |
|             var entries = await db.StreamClaimAsync(
 | |
|                     streamOptions.Stream,
 | |
|                     streamOptions.ConsumerGroup,
 | |
|                     options.ClaimantConsumer,
 | |
|                     0,
 | |
|                     messageIds)
 | |
|                 .ConfigureAwait(false);
 | |
| 
 | |
|             if (entries is null || entries.Length == 0)
 | |
|             {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             var attemptById = eligible
 | |
|                 .Where(static info => !info.MessageId.IsNullOrEmpty)
 | |
|                 .ToDictionary(
 | |
|                     info => info.MessageId!.ToString(),
 | |
|                     info => (int)Math.Max(1, info.DeliveryCount),
 | |
|                     StringComparer.Ordinal);
 | |
| 
 | |
|             foreach (var entry in entries)
 | |
|             {
 | |
|                 var entryId = entry.Id.ToString();
 | |
|                 attemptById.TryGetValue(entryId, out var attempt);
 | |
| 
 | |
|                 var lease = TryMapLease(
 | |
|                     streamOptions,
 | |
|                     entry,
 | |
|                     options.ClaimantConsumer,
 | |
|                     now,
 | |
|                     _options.DefaultLeaseDuration,
 | |
|                     attempt == 0 ? null : attempt);
 | |
| 
 | |
|                 if (lease is null)
 | |
|                 {
 | |
|                     await AckPoisonAsync(db, streamOptions, entry.Id).ConfigureAwait(false);
 | |
|                     continue;
 | |
|                 }
 | |
| 
 | |
|                 leases.Add(lease);
 | |
|                 if (leases.Count >= options.BatchSize)
 | |
|                 {
 | |
|                     return leases;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return leases;
 | |
|     }
 | |
| 
 | |
|     public async ValueTask DisposeAsync()
 | |
|     {
 | |
|         if (_disposed)
 | |
|         {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         _disposed = true;
 | |
|         if (_connection is not null)
 | |
|         {
 | |
|             await _connection.CloseAsync();
 | |
|             _connection.Dispose();
 | |
|         }
 | |
| 
 | |
|         _connectionLock.Dispose();
 | |
|         _groupInitLock.Dispose();
 | |
|         GC.SuppressFinalize(this);
 | |
|     }
 | |
| 
 | |
|     internal async Task AcknowledgeAsync(
 | |
|         RedisNotifyEventLease lease,
 | |
|         CancellationToken cancellationToken)
 | |
|     {
 | |
|         if (!lease.TryBeginCompletion())
 | |
|         {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         var streamOptions = lease.StreamOptions;
 | |
| 
 | |
|         await db.StreamAcknowledgeAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 streamOptions.ConsumerGroup,
 | |
|                 new RedisValue[] { lease.MessageId })
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         await db.StreamDeleteAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 new RedisValue[] { lease.MessageId })
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         NotifyQueueMetrics.RecordAck(TransportName, streamOptions.Stream);
 | |
| 
 | |
|         _logger.LogDebug(
 | |
|             "Acknowledged Notify event {EventId} on consumer {Consumer} (stream {Stream}, id {MessageId}).",
 | |
|             lease.Message.Event.EventId,
 | |
|             lease.Consumer,
 | |
|             streamOptions.Stream,
 | |
|             lease.MessageId);
 | |
|     }
 | |
| 
 | |
|     internal async Task RenewLeaseAsync(
 | |
|         RedisNotifyEventLease lease,
 | |
|         TimeSpan leaseDuration,
 | |
|         CancellationToken cancellationToken)
 | |
|     {
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         var streamOptions = lease.StreamOptions;
 | |
| 
 | |
|         await db.StreamClaimAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 streamOptions.ConsumerGroup,
 | |
|                 lease.Consumer,
 | |
|                 0,
 | |
|                 new RedisValue[] { lease.MessageId })
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
 | |
|         lease.RefreshLease(expires);
 | |
| 
 | |
|         _logger.LogDebug(
 | |
|             "Renewed Notify event lease for {EventId} until {Expires:u}.",
 | |
|             lease.Message.Event.EventId,
 | |
|             expires);
 | |
|     }
 | |
| 
 | |
|     internal Task ReleaseAsync(
 | |
|         RedisNotifyEventLease lease,
 | |
|         NotifyQueueReleaseDisposition disposition,
 | |
|         CancellationToken cancellationToken)
 | |
|         => Task.FromException(new NotSupportedException("Retry/abandon is not supported for Notify event streams."));
 | |
| 
 | |
|     internal async Task DeadLetterAsync(
 | |
|         RedisNotifyEventLease lease,
 | |
|         string reason,
 | |
|         CancellationToken cancellationToken)
 | |
|     {
 | |
|         if (!lease.TryBeginCompletion())
 | |
|         {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         var streamOptions = lease.StreamOptions;
 | |
| 
 | |
|         await db.StreamAcknowledgeAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 streamOptions.ConsumerGroup,
 | |
|                 new RedisValue[] { lease.MessageId })
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         await db.StreamDeleteAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 new RedisValue[] { lease.MessageId })
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         _logger.LogWarning(
 | |
|             "Dead-lettered Notify event {EventId} on stream {Stream} with reason '{Reason}'.",
 | |
|             lease.Message.Event.EventId,
 | |
|             streamOptions.Stream,
 | |
|             reason);
 | |
|     }
 | |
| 
 | |
|     internal async ValueTask PingAsync(CancellationToken cancellationToken)
 | |
|     {
 | |
|         var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
 | |
|         _ = await db.PingAsync().ConfigureAwait(false);
 | |
|     }
 | |
| 
 | |
|     private NotifyRedisEventStreamOptions GetStreamOptions(string stream)
 | |
|     {
 | |
|         if (!_streamsByName.TryGetValue(stream, out var options))
 | |
|         {
 | |
|             throw new InvalidOperationException($"Stream '{stream}' is not configured for the Notify event queue.");
 | |
|         }
 | |
| 
 | |
|         return options;
 | |
|     }
 | |
| 
 | |
|     private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
 | |
|     {
 | |
|         if (_connection is { IsConnected: true })
 | |
|         {
 | |
|             return _connection.GetDatabase(_redisOptions.Database ?? -1);
 | |
|         }
 | |
| 
 | |
|         await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 | |
|         try
 | |
|         {
 | |
|             if (_connection is { IsConnected: true })
 | |
|             {
 | |
|                 return _connection.GetDatabase(_redisOptions.Database ?? -1);
 | |
|             }
 | |
| 
 | |
|             var configuration = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
 | |
|             configuration.AbortOnConnectFail = false;
 | |
|             if (_redisOptions.Database.HasValue)
 | |
|             {
 | |
|                 configuration.DefaultDatabase = _redisOptions.Database;
 | |
|             }
 | |
| 
 | |
|             using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 | |
|             timeoutCts.CancelAfter(_redisOptions.InitializationTimeout);
 | |
| 
 | |
|             _connection = await _connectionFactory(configuration).WaitAsync(timeoutCts.Token).ConfigureAwait(false);
 | |
|             return _connection.GetDatabase(_redisOptions.Database ?? -1);
 | |
|         }
 | |
|         finally
 | |
|         {
 | |
|             _connectionLock.Release();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private async Task EnsureStreamInitializedAsync(
 | |
|         IDatabase database,
 | |
|         NotifyRedisEventStreamOptions streamOptions,
 | |
|         CancellationToken cancellationToken)
 | |
|     {
 | |
|         if (_initializedStreams.ContainsKey(streamOptions.Stream))
 | |
|         {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 | |
|         try
 | |
|         {
 | |
|             if (_initializedStreams.ContainsKey(streamOptions.Stream))
 | |
|             {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             try
 | |
|             {
 | |
|                 await database.StreamCreateConsumerGroupAsync(
 | |
|                         streamOptions.Stream,
 | |
|                         streamOptions.ConsumerGroup,
 | |
|                         StreamPosition.Beginning,
 | |
|                         createStream: true)
 | |
|                     .ConfigureAwait(false);
 | |
|             }
 | |
|             catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
 | |
|             {
 | |
|                 // Consumer group already exists — nothing to do.
 | |
|             }
 | |
| 
 | |
|             _initializedStreams[streamOptions.Stream] = true;
 | |
|         }
 | |
|         finally
 | |
|         {
 | |
|             _groupInitLock.Release();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private static async Task<RedisValue> AddToStreamAsync(
 | |
|         IDatabase database,
 | |
|         NotifyRedisEventStreamOptions streamOptions,
 | |
|         IReadOnlyList<NameValueEntry> entries)
 | |
|     {
 | |
|         return await database.StreamAddAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 entries.ToArray(),
 | |
|                 maxLength: streamOptions.ApproximateMaxLength,
 | |
|                 useApproximateMaxLength: streamOptions.ApproximateMaxLength is not null)
 | |
|             .ConfigureAwait(false);
 | |
|     }
 | |
| 
 | |
|     private IReadOnlyList<NameValueEntry> BuildEntries(
 | |
|         NotifyQueueEventMessage message,
 | |
|         DateTimeOffset enqueuedAt,
 | |
|         int attempt)
 | |
|     {
 | |
|         var payload = NotifyCanonicalJsonSerializer.Serialize(message.Event);
 | |
| 
 | |
|         var entries = new List<NameValueEntry>(8 + message.Attributes.Count)
 | |
|         {
 | |
|             new(NotifyQueueFields.Payload, payload),
 | |
|             new(NotifyQueueFields.EventId, message.Event.EventId.ToString("D")),
 | |
|             new(NotifyQueueFields.Tenant, message.TenantId),
 | |
|             new(NotifyQueueFields.Kind, message.Event.Kind),
 | |
|             new(NotifyQueueFields.Attempt, attempt),
 | |
|             new(NotifyQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds()),
 | |
|             new(NotifyQueueFields.IdempotencyKey, message.IdempotencyKey),
 | |
|             new(NotifyQueueFields.PartitionKey, message.PartitionKey ?? string.Empty),
 | |
|             new(NotifyQueueFields.TraceId, message.TraceId ?? string.Empty)
 | |
|         };
 | |
| 
 | |
|         foreach (var kvp in message.Attributes)
 | |
|         {
 | |
|             entries.Add(new NameValueEntry(
 | |
|                 NotifyQueueFields.AttributePrefix + kvp.Key,
 | |
|                 kvp.Value));
 | |
|         }
 | |
| 
 | |
|         return entries;
 | |
|     }
 | |
| 
 | |
|     private RedisNotifyEventLease? TryMapLease(
 | |
|         NotifyRedisEventStreamOptions streamOptions,
 | |
|         StreamEntry entry,
 | |
|         string consumer,
 | |
|         DateTimeOffset now,
 | |
|         TimeSpan leaseDuration,
 | |
|         int? attemptOverride)
 | |
|     {
 | |
|         if (entry.Values is null || entry.Values.Length == 0)
 | |
|         {
 | |
|             return null;
 | |
|         }
 | |
| 
 | |
|         string? payloadJson = null;
 | |
|         string? eventIdRaw = null;
 | |
|         long? enqueuedAtUnix = null;
 | |
|         string? idempotency = null;
 | |
|         string? partitionKey = null;
 | |
|         string? traceId = null;
 | |
|         var attempt = attemptOverride ?? 1;
 | |
|         var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
 | |
| 
 | |
|         foreach (var field in entry.Values)
 | |
|         {
 | |
|             var name = field.Name.ToString();
 | |
|             var value = field.Value;
 | |
|             if (name.Equals(NotifyQueueFields.Payload, StringComparison.Ordinal))
 | |
|             {
 | |
|                 payloadJson = value.ToString();
 | |
|             }
 | |
|             else if (name.Equals(NotifyQueueFields.EventId, StringComparison.Ordinal))
 | |
|             {
 | |
|                 eventIdRaw = value.ToString();
 | |
|             }
 | |
|             else if (name.Equals(NotifyQueueFields.Attempt, StringComparison.Ordinal))
 | |
|             {
 | |
|                 if (int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
 | |
|                 {
 | |
|                     attempt = Math.Max(parsed, attempt);
 | |
|                 }
 | |
|             }
 | |
|             else if (name.Equals(NotifyQueueFields.EnqueuedAt, StringComparison.Ordinal))
 | |
|             {
 | |
|                 if (long.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix))
 | |
|                 {
 | |
|                     enqueuedAtUnix = unix;
 | |
|                 }
 | |
|             }
 | |
|             else if (name.Equals(NotifyQueueFields.IdempotencyKey, StringComparison.Ordinal))
 | |
|             {
 | |
|                 var text = value.ToString();
 | |
|                 idempotency = string.IsNullOrWhiteSpace(text) ? null : text;
 | |
|             }
 | |
|             else if (name.Equals(NotifyQueueFields.PartitionKey, StringComparison.Ordinal))
 | |
|             {
 | |
|                 var text = value.ToString();
 | |
|                 partitionKey = string.IsNullOrWhiteSpace(text) ? null : text;
 | |
|             }
 | |
|             else if (name.Equals(NotifyQueueFields.TraceId, StringComparison.Ordinal))
 | |
|             {
 | |
|                 var text = value.ToString();
 | |
|                 traceId = string.IsNullOrWhiteSpace(text) ? null : text;
 | |
|             }
 | |
|             else if (name.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
 | |
|             {
 | |
|                 var key = name[NotifyQueueFields.AttributePrefix.Length..];
 | |
|                 attributes[key] = value.ToString();
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (payloadJson is null || enqueuedAtUnix is null)
 | |
|         {
 | |
|             return null;
 | |
|         }
 | |
| 
 | |
|         NotifyEvent notifyEvent;
 | |
|         try
 | |
|         {
 | |
|             notifyEvent = NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(payloadJson);
 | |
|         }
 | |
|         catch (Exception ex)
 | |
|         {
 | |
|             _logger.LogWarning(
 | |
|                 ex,
 | |
|                 "Failed to deserialize Notify event payload for stream {Stream} entry {EntryId}.",
 | |
|                 streamOptions.Stream,
 | |
|                 entry.Id.ToString());
 | |
|             return null;
 | |
|         }
 | |
| 
 | |
|         var attributeView = attributes.Count == 0
 | |
|             ? EmptyReadOnlyDictionary<string, string>.Instance
 | |
|             : new ReadOnlyDictionary<string, string>(attributes);
 | |
| 
 | |
|         var message = new NotifyQueueEventMessage(
 | |
|             notifyEvent,
 | |
|             streamOptions.Stream,
 | |
|             idempotencyKey: idempotency ?? notifyEvent.EventId.ToString("N"),
 | |
|             partitionKey: partitionKey,
 | |
|             traceId: traceId,
 | |
|             attributes: attributeView);
 | |
| 
 | |
|         var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
 | |
|         var leaseExpiresAt = now.Add(leaseDuration);
 | |
| 
 | |
|         return new RedisNotifyEventLease(
 | |
|             this,
 | |
|             streamOptions,
 | |
|             entry.Id.ToString(),
 | |
|             message,
 | |
|             attempt,
 | |
|             consumer,
 | |
|             enqueuedAt,
 | |
|             leaseExpiresAt);
 | |
|     }
 | |
| 
 | |
|     private async Task AckPoisonAsync(
 | |
|         IDatabase database,
 | |
|         NotifyRedisEventStreamOptions streamOptions,
 | |
|         RedisValue messageId)
 | |
|     {
 | |
|         await database.StreamAcknowledgeAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 streamOptions.ConsumerGroup,
 | |
|                 new RedisValue[] { messageId })
 | |
|             .ConfigureAwait(false);
 | |
| 
 | |
|         await database.StreamDeleteAsync(
 | |
|                 streamOptions.Stream,
 | |
|                 new RedisValue[] { messageId })
 | |
|             .ConfigureAwait(false);
 | |
|     }
 | |
| }
 |