@@ -14,7 +14,7 @@ namespace Foundatio.Messaging;
1414
1515public interface IKafkaMessageBus : IMessageBus
1616{
17- Task DeleteTopicAsync ( ) ;
17+ Task DeleteTopicAsync ( CancellationToken cancellationToken = default ) ;
1818}
1919
2020public class KafkaMessageBus : MessageBusBase < KafkaMessageBusOptions > , IKafkaMessageBus
@@ -26,7 +26,6 @@ public class KafkaMessageBus : MessageBusBase<KafkaMessageBusOptions>, IKafkaMes
2626 private readonly AsyncLock _lock = new ( ) ;
2727 private readonly AsyncManualResetEvent _consumerReady = new ( ) ;
2828 private bool _topicCreated ;
29- private bool _isDisposed ;
3029
3130 public KafkaMessageBus ( KafkaMessageBusOptions options ) : base ( options )
3231 {
@@ -96,6 +95,9 @@ protected override async Task PublishImplAsync(string messageType, object messag
9695
9796 private async Task OnMessageAsync ( IConsumer < string , byte [ ] > consumer , ConsumeResult < string , byte [ ] > consumeResult )
9897 {
98+ // No subscribers registered — skip processing but do NOT commit the offset.
99+ // Kafka retains uncommitted messages so they are redelivered once a subscriber
100+ // reconnects, which is the expected persistence-guarantee behavior.
99101 if ( _subscribers . IsEmpty )
100102 return ;
101103
@@ -105,6 +107,7 @@ private async Task OnMessageAsync(IConsumer<string, byte[]> consumer, ConsumeRes
105107
106108 _logger . LogTrace ( "OnMessage(TopicPartitionOffset={TopicPartitionOffset} GroupId={GroupId})" , consumeResult . TopicPartitionOffset , _consumerConfig . GroupId ) ;
107109
110+ bool shouldAcknowledge = true ;
108111 try
109112 {
110113 string ? messageType = _options . ResolveMessageType ? . Invoke ( consumeResult ) ;
@@ -124,6 +127,11 @@ private async Task OnMessageAsync(IConsumer<string, byte[]> consumer, ConsumeRes
124127 var message = ConvertToMessage ( messageType , consumeResult . Message ) ;
125128 await SendMessageToSubscribersAsync ( message ) . AnyContext ( ) ;
126129 }
130+ catch ( OperationCanceledException ) when ( IsDisposed )
131+ {
132+ // Bus is disposing — skip offset commit so the message is redelivered to other consumers
133+ shouldAcknowledge = false ;
134+ }
127135 catch ( MessageBusException )
128136 {
129137 // Subscriber handler failures are application-level errors. The base class
@@ -138,7 +146,9 @@ private async Task OnMessageAsync(IConsumer<string, byte[]> consumer, ConsumeRes
138146 }
139147 finally
140148 {
141- if ( ! _subscribers . IsEmpty )
149+ // Commit the offset unless the bus is disposing, in which case we skip so
150+ // the message is redelivered to other consumers in the group.
151+ if ( shouldAcknowledge && ! IsDisposed )
142152 AcknowledgeMessage ( consumer , consumeResult ) ;
143153 }
144154 }
@@ -295,23 +305,33 @@ private void EnsureListening()
295305 } , DisposedCancellationToken ) ;
296306 }
297307
298- public override void Dispose ( )
308+ protected override async Task CleanupAsync ( )
299309 {
300- if ( _isDisposed )
310+ if ( _listeningTask is not null )
301311 {
302- _logger . LogTrace ( "MessageBus {MessageBusId} dispose was already called" , MessageBusId ) ;
303- return ;
312+ try
313+ {
314+ await _listeningTask . WaitAsync ( TimeSpan . FromSeconds ( 15 ) ) . AnyContext ( ) ;
315+ }
316+ catch ( OperationCanceledException ex )
317+ {
318+ _logger . LogTrace ( ex , "Listening task cancelled during cleanup" ) ;
319+ }
320+ catch ( TimeoutException )
321+ {
322+ _logger . LogWarning ( "Listening task did not complete within timeout during dispose" ) ;
323+ }
324+ catch ( Exception ex )
325+ {
326+ _logger . LogWarning ( ex , "Error waiting for listening task to complete during cleanup" ) ;
327+ }
304328 }
305329
306- _isDisposed = true ;
307- _logger . LogTrace ( "MessageBus {MessageBusId} dispose" , MessageBusId ) ;
308-
309330 int ? queueSize = _producer ? . Flush ( TimeSpan . FromSeconds ( 15 ) ) ;
310331 if ( queueSize > 0 )
311332 _logger . LogTrace ( "Flushed producer {queueSize}" , queueSize ) ;
312333
313334 _producer ? . Dispose ( ) ;
314- base . Dispose ( ) ;
315335 }
316336
317337 private async Task EnsureTopicCreatedAsync ( )
@@ -384,9 +404,13 @@ private async Task EnsureTopicCreatedAsync()
384404 }
385405 }
386406
387- async Task IKafkaMessageBus . DeleteTopicAsync ( )
407+ async Task IKafkaMessageBus . DeleteTopicAsync ( CancellationToken cancellationToken )
388408 {
389- using var topicLock = await _lock . LockAsync ( DisposedCancellationToken ) . AnyContext ( ) ;
409+ ObjectDisposedException . ThrowIf ( IsDisposed , this ) ;
410+
411+ using var linkedCts = CancellationTokenSource . CreateLinkedTokenSource (
412+ cancellationToken , DisposedCancellationToken ) ;
413+ using var topicLock = await _lock . LockAsync ( linkedCts . Token ) . AnyContext ( ) ;
390414 using var adminClient = new AdminClientBuilder ( _adminClientConfig )
391415 . SetLogHandler ( LogHandler )
392416 . SetStatisticsHandler ( LogStatisticsHandler )
0 commit comments