├── .editorconfig ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── Confluent.Kafka.Dataflow ├── Blocks │ ├── ContinueBlock.cs │ └── CustomBlock.cs ├── ClientExtensions.cs ├── Confluent.Kafka.Dataflow.csproj ├── Consuming │ └── MessageLoader.cs ├── Producing │ ├── IMessageSender.cs │ ├── ITransactor.cs │ ├── MessageSender.cs │ ├── MessageTransactor.cs │ ├── OffsetTransactor.cs │ └── TransactionFlusher.cs └── TargetBuilder.cs ├── LICENSE ├── README.md ├── global.json ├── icon.png └── kafka-dataflow.sln /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | 3 | charset = utf-8 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NuGet.org 2 | on: { release: { types: [ published ] } } 3 | jobs: 4 | publish: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-dotnet@v3 9 | - name: Create package(s) 10 | run: dotnet pack --configuration Release -property:Version=${GITHUB_REF_NAME#v} 11 | - name: Upload package(s) 12 | run: > 13 | dotnet nuget push */bin/Release/*.nupkg 14 | --source https://api.nuget.org/v3/index.json 15 | --api-key ${{ secrets.NUGET_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | 4 | .vs/ 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Blocks/ContinueBlock.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Blocks 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Threading.Tasks.Dataflow; 7 | 8 | class ContinueBlock : ITargetBlock 9 | { 10 | readonly ITargetBlock target; 11 | 12 | public ContinueBlock(ITargetBlock target, Func continuation, DataflowBlockOptions options) 13 | { 14 | this.target = target; 15 | this.Completion = this.target.Completion.ContinueWith( 16 | (task, obj) => 17 | { 18 | if (task.IsFaulted) 19 | { 20 | return task; 21 | } 22 | 23 | return ((Func)obj!).Invoke(); 24 | }, 25 | continuation, 26 | CancellationToken.None, 27 | TaskContinuationOptions.ExecuteSynchronously, 28 | options.TaskScheduler) 29 | .Unwrap(); 30 | } 31 | 32 | public Task Completion { get; } 33 | 34 | public DataflowMessageStatus OfferMessage( 35 | DataflowMessageHeader messageHeader, 36 | T messageValue, 37 | ISourceBlock? source, 38 | bool consumeToAccept) 39 | { 40 | return this.target.OfferMessage(messageHeader, messageValue, source, consumeToAccept); 41 | } 42 | 43 | public void Complete() => this.target.Complete(); 44 | 45 | public void Fault(Exception exception) => this.target.Fault(exception); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Blocks/CustomBlock.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Blocks 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Threading.Tasks.Dataflow; 7 | 8 | class CustomBlock : IPropagatorBlock 9 | { 10 | readonly Func, CancellationToken, Task> executor; 11 | 12 | readonly BufferBlock buffer; 13 | readonly CancellationTokenSource execution = new(); 14 | 15 | public CustomBlock(Func, CancellationToken, Task> executor, DataflowBlockOptions options) 16 | { 17 | this.executor = executor; 18 | 19 | this.buffer = new(options); 20 | this.buffer.Completion.ContinueWith( 21 | (_, obj) => ((CancellationTokenSource)obj!).Cancel(), 22 | this.execution, 23 | CancellationToken.None, 24 | TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.NotOnRanToCompletion, 25 | options.TaskScheduler); 26 | 27 | var executeTask = Task.Factory.StartNew( 28 | obj => ((CustomBlock)obj!).Execute(), 29 | this, 30 | this.execution.Token, 31 | TaskCreationOptions.None, 32 | options.TaskScheduler) 33 | .Unwrap(); 34 | 35 | this.Completion = executeTask.ContinueWith( 36 | (task, obj) => 37 | { 38 | var block = (IDataflowBlock)obj!; 39 | 40 | if (task.IsFaulted) 41 | { 42 | block.Fault(task.Exception!); 43 | } 44 | else 45 | { 46 | block.Complete(); 47 | } 48 | 49 | return block.Completion; 50 | }, 51 | this.buffer, 52 | CancellationToken.None, 53 | TaskContinuationOptions.ExecuteSynchronously, 54 | options.TaskScheduler) 55 | .Unwrap(); 56 | } 57 | 58 | public Task Completion { get; } 59 | 60 | public DataflowMessageStatus OfferMessage( 61 | DataflowMessageHeader messageHeader, 62 | T messageValue, 63 | ISourceBlock? source, 64 | bool consumeToAccept) 65 | { 66 | return ((ITargetBlock)this.buffer).OfferMessage(messageHeader, messageValue, source, consumeToAccept); 67 | } 68 | 69 | public IDisposable LinkTo(ITargetBlock target, DataflowLinkOptions linkOptions) => 70 | ((ISourceBlock)this.buffer).LinkTo(target, linkOptions); 71 | 72 | public T? ConsumeMessage(DataflowMessageHeader messageHeader, ITargetBlock target, out bool messageConsumed) 73 | { 74 | return ((ISourceBlock)this.buffer).ConsumeMessage(messageHeader, target, out messageConsumed); 75 | } 76 | 77 | public bool ReserveMessage(DataflowMessageHeader messageHeader, ITargetBlock target) => 78 | ((ISourceBlock)this.buffer).ReserveMessage(messageHeader, target); 79 | 80 | public void ReleaseReservation(DataflowMessageHeader messageHeader, ITargetBlock target) => 81 | ((ISourceBlock)this.buffer).ReleaseReservation(messageHeader, target); 82 | 83 | public void Complete() => this.execution.Cancel(); 84 | 85 | public void Fault(Exception exception) => ((IDataflowBlock)this.buffer).Fault(exception); 86 | 87 | private Task Execute() => this.executor(this.buffer, this.execution.Token); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/ClientExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow 2 | { 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Threading.Tasks; 6 | using System.Threading.Tasks.Dataflow; 7 | using Confluent.Kafka.Dataflow.Blocks; 8 | using Confluent.Kafka.Dataflow.Consuming; 9 | 10 | /// 11 | /// Extensions to represent Kafka producers and consumers as dataflow blocks. 12 | /// 13 | public static class ClientExtensions 14 | { 15 | /// 16 | /// Represents a consumer as a source block for Kafka messages. 17 | /// 18 | /// 19 | /// Consumer must be subscribed/assigned for messages to be received. 20 | /// 21 | /// The consumer key type. 22 | /// The consumer value type. 23 | /// The consumer. 24 | /// A delegate invoked for each Kafka message (before offering to targets). 25 | /// Block options for consuming. 26 | /// The consumer source block. 27 | public static ISourceBlock> AsSourceBlock( 28 | this IConsumer consumer, 29 | Action, TopicPartitionOffset>? handler = null, 30 | DataflowBlockOptions? options = null) 31 | { 32 | var loader = new MessageLoader(consumer ?? throw new ArgumentNullException(nameof(consumer))); 33 | loader.OnConsumed += handler; 34 | 35 | return new CustomBlock>(loader.Load, options ?? new()); 36 | } 37 | 38 | /// 39 | /// Represents a consumer as a source block for Kafka messages, with a linked commit target. 40 | /// 41 | /// 42 | /// Consumer must be subscribed/assigned and have enable.auto.offset.store set to false. 43 | /// 44 | /// The consumer key type. 45 | /// The consumer value type. 46 | /// The consumer. 47 | /// 48 | /// A target block for committing processed messages. Order must be preserved. 49 | /// 50 | /// A delegate invoked for each Kafka message (before offering to targets). 51 | /// Block options for consuming. 52 | /// The consumer source block. 53 | public static ISourceBlock> AsSourceBlock( 54 | this IConsumer consumer, 55 | out ITargetBlock> commitTarget, 56 | Action, TopicPartitionOffset>? handler = null, 57 | DataflowBlockOptions? options = null) 58 | { 59 | var buffer = new ConcurrentQueue<(Message, TopicPartitionOffset)>(); 60 | 61 | var loader = new MessageLoader(consumer ?? throw new ArgumentNullException(nameof(consumer))); 62 | loader.OnConsumed += handler + Store; 63 | 64 | void Store(Message message, TopicPartitionOffset offset) 65 | { 66 | buffer.Enqueue((message, offset)); 67 | } 68 | 69 | options ??= new(); 70 | 71 | var target = new ActionBlock>( 72 | message => 73 | { 74 | if (!buffer.TryDequeue(out var stored) || stored.Item1 != message) 75 | { 76 | throw new InvalidOperationException("Unexpected message."); 77 | } 78 | 79 | consumer.StoreOffset( 80 | new TopicPartitionOffset(stored.Item2.TopicPartition, stored.Item2.Offset + 1)); 81 | }, 82 | new() 83 | { 84 | BoundedCapacity = options.BoundedCapacity, 85 | CancellationToken = options.CancellationToken, 86 | EnsureOrdered = options.EnsureOrdered, 87 | NameFormat = options.NameFormat, 88 | MaxMessagesPerTask = options.MaxMessagesPerTask, 89 | TaskScheduler = options.TaskScheduler, 90 | MaxDegreeOfParallelism = 1, 91 | SingleProducerConstrained = true, 92 | }); 93 | 94 | commitTarget = new ContinueBlock>( 95 | target, 96 | () => Task.Factory.StartNew( 97 | consumer.Commit, 98 | options.CancellationToken, 99 | TaskCreationOptions.LongRunning, 100 | options.TaskScheduler), 101 | options); 102 | 103 | return new CustomBlock>(loader.Load, options); 104 | } 105 | 106 | /// 107 | /// Represents a producer as a target block for Kafka messages. 108 | /// 109 | /// 110 | /// A producer can be represented as multiple targets. 111 | /// 112 | /// The producer key type. 113 | /// The producer value type. 114 | /// The producer. 115 | /// 116 | /// The topic/partition receiving the messages. Use for automatic partitioning. 117 | /// 118 | /// A delegate invoked for each delivered message, with its offset. 119 | /// Block options for producing. 120 | /// The producer target block. 121 | public static ITargetBlock> AsTargetBlock( 122 | this IProducer producer, 123 | TopicPartition topicPartition, 124 | Action, TopicPartitionOffset>? handler = null, 125 | DataflowBlockOptions? options = null) 126 | { 127 | if (producer == null) 128 | { 129 | throw new ArgumentNullException(nameof(producer)); 130 | } 131 | 132 | if (topicPartition == null) 133 | { 134 | throw new ArgumentNullException(nameof(topicPartition)); 135 | } 136 | 137 | options ??= new(); 138 | 139 | return new ActionBlock>( 140 | async message => 141 | { 142 | var result = await producer.ProduceAsync(topicPartition, message).ConfigureAwait(false); 143 | handler?.Invoke(result.Message, result.TopicPartitionOffset); 144 | }, 145 | new() 146 | { 147 | BoundedCapacity = options.BoundedCapacity, 148 | CancellationToken = options.CancellationToken, 149 | EnsureOrdered = options.EnsureOrdered, 150 | NameFormat = options.NameFormat, 151 | MaxMessagesPerTask = options.MaxMessagesPerTask, 152 | TaskScheduler = options.TaskScheduler, 153 | MaxDegreeOfParallelism = DataflowBlockOptions.Unbounded, 154 | SingleProducerConstrained = false, 155 | }); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Confluent.Kafka.Dataflow.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | all 5 | true 6 | enable 7 | latest 8 | enable 9 | Confluent.Kafka.Dataflow 10 | netstandard2.0;net5.0 11 | 12 | 13 | 14 | Kyle McClellan 15 | %A9 2021-2023 Kyle McClellan 16 | An extension of Confluent.Kafka for use with Microsoft.Extensions.Dataflow. 17 | true 18 | true 19 | icon.png 20 | MIT 21 | https://github.com/kmcclellan/kafka-dataflow 22 | README.md 23 | https://github.com/kmcclellan/kafka-dataflow/releases/v$(Version) 24 | true 25 | kafka;confluent;dataflow 26 | snupkg 27 | 28 | 29 | 30 | 31 | <_Parameter1>true 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Consuming/MessageLoader.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Consuming 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Threading.Tasks.Dataflow; 7 | 8 | class MessageLoader 9 | { 10 | readonly IConsumer consumer; 11 | 12 | public MessageLoader(IConsumer consumer) 13 | { 14 | this.consumer = consumer; 15 | } 16 | 17 | public event Action, TopicPartitionOffset>? OnConsumed; 18 | 19 | public async Task Load(ITargetBlock> target, CancellationToken cancellationToken) 20 | { 21 | ConsumeResult result; 22 | 23 | while (true) 24 | { 25 | result = this.consumer.Consume(TimeSpan.Zero) ?? 26 | await this.ConsumeAsync(cancellationToken).ConfigureAwait(false); 27 | 28 | if (result.IsPartitionEOF) 29 | { 30 | continue; 31 | } 32 | 33 | this.OnConsumed?.Invoke(result.Message, result.TopicPartitionOffset); 34 | 35 | if (!target.Post(result.Message) && 36 | !await target.SendAsync(result.Message, CancellationToken.None).ConfigureAwait(false)) 37 | { 38 | throw new InvalidOperationException("Target rejected message!"); 39 | } 40 | } 41 | } 42 | 43 | private Task> ConsumeAsync(CancellationToken cancellationToken) 44 | { 45 | return Task.Factory.StartNew( 46 | obj => 47 | { 48 | var (c, ct) = ((IConsumer, CancellationToken))obj!; 49 | return c.Consume(ct); 50 | }, 51 | (this.consumer, cancellationToken), 52 | cancellationToken, 53 | TaskCreationOptions.LongRunning, 54 | TaskScheduler.Current); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Producing/IMessageSender.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Producing 2 | { 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | 6 | interface IMessageSender 7 | { 8 | IEnumerable> Send(T item); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Producing/ITransactor.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Producing 2 | { 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | 6 | interface ITransactor 7 | { 8 | Task>> Send( 9 | IEnumerable items, 10 | IEnumerable offsets); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Producing/MessageSender.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Producing 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | using Confluent.Kafka; 7 | 8 | class MessageSender : IMessageSender 9 | { 10 | readonly IProducer producer; 11 | readonly Func>> mapping; 12 | readonly TopicPartition topicPartition; 13 | 14 | public MessageSender( 15 | IProducer producer, 16 | Func>> mapping, 17 | TopicPartition topicPartition) 18 | { 19 | this.producer = producer; 20 | this.mapping = mapping; 21 | this.topicPartition = topicPartition; 22 | } 23 | 24 | public IEnumerable> Send(T item) 25 | { 26 | foreach (var message in this.mapping(item)) 27 | { 28 | yield return this.ProduceOffset(message); 29 | } 30 | } 31 | 32 | private async Task ProduceOffset(Message message) 33 | { 34 | var result = await this.producer.ProduceAsync(this.topicPartition, message).ConfigureAwait(false); 35 | return result.TopicPartitionOffset; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Producing/MessageTransactor.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Producing 2 | { 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Confluent.Kafka; 8 | 9 | class MessageTransactor : ITransactor 10 | { 11 | readonly IProducer producer; 12 | readonly IEnumerable> senders; 13 | readonly IConsumerGroupMetadata? consumerMetadata; 14 | 15 | public MessageTransactor( 16 | IProducer producer, 17 | IEnumerable> senders, 18 | IConsumerGroupMetadata? consumerMetadata) 19 | { 20 | this.producer = producer; 21 | this.senders = senders; 22 | this.consumerMetadata = consumerMetadata; 23 | } 24 | 25 | public async Task>> Send( 26 | IEnumerable items, 27 | IEnumerable offsets) 28 | { 29 | this.producer.BeginTransaction(); 30 | 31 | var produced = await Task.WhenAll(SendAll()).ConfigureAwait(false); 32 | 33 | IEnumerable>> SendAll() 34 | { 35 | var enumerator = items.GetEnumerator(); 36 | for (var index = 0; enumerator.MoveNext(); index++) 37 | { 38 | foreach (var sender in this.senders) 39 | { 40 | foreach (var task in sender.Send(enumerator.Current)) 41 | { 42 | yield return Wrap(index, task); 43 | } 44 | } 45 | } 46 | } 47 | 48 | static async Task> Wrap(int index, Task task) 49 | { 50 | return new KeyValuePair(index, await task.ConfigureAwait(false)); 51 | } 52 | 53 | await Task.Factory.StartNew( 54 | obj => 55 | { 56 | var (t, o) = ((MessageTransactor, IEnumerable))obj!; 57 | t.FinishSync(o); 58 | }, 59 | (this, offsets), 60 | CancellationToken.None, 61 | TaskCreationOptions.LongRunning, 62 | TaskScheduler.Current) 63 | .ConfigureAwait(false); 64 | 65 | return produced; 66 | } 67 | 68 | private void FinishSync(IEnumerable offsets) 69 | { 70 | if (this.consumerMetadata != null) 71 | { 72 | this.producer.SendOffsetsToTransaction( 73 | offsets.ToArray(), 74 | this.consumerMetadata, 75 | Timeout.InfiniteTimeSpan); 76 | } 77 | 78 | this.producer.CommitTransaction(); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Producing/OffsetTransactor.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Producing 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Confluent.Kafka; 9 | 10 | class OffsetTransactor : ITransactor 11 | { 12 | readonly IConsumer consumer; 13 | 14 | public OffsetTransactor(IConsumer consumer) 15 | { 16 | this.consumer = consumer; 17 | } 18 | 19 | public async Task>> Send( 20 | IEnumerable items, 21 | IEnumerable offsets) 22 | { 23 | await Task.Factory.StartNew( 24 | obj => 25 | { 26 | var (c, o) = ((IConsumer, IEnumerable))obj!; 27 | c.Commit(o.ToArray()); 28 | }, 29 | (this.consumer, offsets), 30 | CancellationToken.None, 31 | TaskCreationOptions.LongRunning, 32 | TaskScheduler.Current) 33 | .ConfigureAwait(false); 34 | 35 | return Array.Empty>(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/Producing/TransactionFlusher.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow.Producing 2 | { 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using System.Threading.Tasks.Dataflow; 10 | 11 | class TransactionFlusher 12 | { 13 | readonly ITransactor transactor; 14 | readonly TimeSpan interval; 15 | readonly IProducerConsumerCollection? offsetSource; 16 | readonly Func? offsetMapping; 17 | 18 | public TransactionFlusher( 19 | ITransactor transactor, 20 | TimeSpan interval, 21 | IProducerConsumerCollection? offsetSource, 22 | Func? offsetMapping) 23 | { 24 | this.transactor = transactor; 25 | this.interval = interval; 26 | this.offsetSource = offsetSource; 27 | this.offsetMapping = offsetMapping; 28 | } 29 | 30 | public event Action>? OnDelivered; 31 | 32 | public async Task Flush(IReceivableSourceBlock source, CancellationToken cancellationToken) 33 | { 34 | while (!source.Completion.IsCompleted) 35 | { 36 | try 37 | { 38 | await Task.Delay(this.interval, cancellationToken).ConfigureAwait(false); 39 | } 40 | catch (OperationCanceledException) 41 | { 42 | source.Complete(); 43 | } 44 | 45 | if (source.TryReceiveAll(out var items)) 46 | { 47 | var itemOffsets = Enumerable.Range(0, items.Count) 48 | .Select(_ => new List()) 49 | .ToArray(); 50 | 51 | IEnumerable commitOffsets; 52 | 53 | if (this.offsetSource == null) 54 | { 55 | commitOffsets = Array.Empty(); 56 | } 57 | else 58 | { 59 | var commitPositions = new Dictionary(); 60 | 61 | for (var i = 0; i < items.Count; i++) 62 | { 63 | var n = this.offsetMapping?.Invoke(items[i]) ?? 1; 64 | 65 | while (n-- > 0) 66 | { 67 | if (!this.offsetSource.TryTake(out var tpo)) 68 | { 69 | throw new InvalidOperationException("No offset stored!"); 70 | } 71 | 72 | itemOffsets[i].Add(tpo); 73 | commitPositions[tpo.TopicPartition] = tpo.Offset; 74 | } 75 | } 76 | 77 | commitOffsets = commitPositions.Select( 78 | kvp => new TopicPartitionOffset(kvp.Key, kvp.Value + 1)); 79 | } 80 | 81 | var published = await this.transactor.Send(items, commitOffsets).ConfigureAwait(false); 82 | 83 | foreach (var kvp in published) 84 | { 85 | itemOffsets[kvp.Key].Add(kvp.Value); 86 | } 87 | 88 | for (var i = 0; i < items.Count; i++) 89 | { 90 | this.OnDelivered?.Invoke(items[i], itemOffsets[i]); 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Confluent.Kafka.Dataflow/TargetBuilder.cs: -------------------------------------------------------------------------------- 1 | namespace Confluent.Kafka.Dataflow 2 | { 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks.Dataflow; 7 | using Confluent.Kafka.Dataflow.Blocks; 8 | using Confluent.Kafka.Dataflow.Consuming; 9 | using Confluent.Kafka.Dataflow.Producing; 10 | 11 | /// 12 | /// A builder of instances to send data to Kafka transactionally (producing 13 | /// and/or committing consumed offsets) . 14 | /// 15 | /// 16 | /// Use members to configure what data is sent for . All data mapped to a given instance 17 | /// will be included in the same transaction. 18 | /// 19 | /// The target data type. 20 | public class TargetBuilder 21 | { 22 | readonly List> messageSenders = new(); 23 | 24 | IClient? producerHandle; 25 | IProducerConsumerCollection? offsetSource; 26 | Func? offsetMapping; 27 | IConsumerGroupMetadata? consumerMetadata; 28 | ITransactor? commitTransactor; 29 | 30 | /// 31 | /// Configures transactional producing of Kafka messages. 32 | /// 33 | /// 34 | /// Producer must be initialized with . Invoke multiple 35 | /// times to send to different destinations using the same underlying client. 36 | /// 37 | /// The producer key type. 38 | /// The producer value type. 39 | /// The target producer for these messages. 40 | /// A delegate mapping to zero or more Kafka messages. 41 | /// 42 | /// The topic/partition receiving the messages. Use for automatic partitioning. 43 | /// 44 | /// The same instance for chaining. 45 | public TargetBuilder WithMessages( 46 | IProducer producer, 47 | Func>> mapping, 48 | TopicPartition topicPartition) 49 | { 50 | if (producer == null) 51 | { 52 | throw new ArgumentNullException(nameof(producer)); 53 | } 54 | 55 | if (this.producerHandle != null && this.producerHandle.Name != producer.Name) 56 | { 57 | throw new InvalidOperationException("Already configured with a different underlying client!"); 58 | } 59 | 60 | var sender = new MessageSender( 61 | producer, 62 | mapping ?? throw new ArgumentNullException(nameof(mapping)), 63 | topicPartition ?? throw new ArgumentNullException(nameof(topicPartition))); 64 | 65 | this.messageSenders.Add(sender); 66 | this.producerHandle = producer; 67 | 68 | return this; 69 | } 70 | 71 | /// 72 | /// Configures the target as a stream with a linked source. 73 | /// 74 | /// 75 | /// Source offsets will be committed transactionally with produced messages. Consumer must have 76 | /// enable.auto.commit set to false. 77 | /// 78 | /// The consumer key type. 79 | /// The consumer value type. 80 | /// The consumer to use as the source for Kafka messages. 81 | /// The linked source instance. 82 | /// A delegate invoked for each source Kafka message (before offering to targets). 83 | /// Block options for source consuming. 84 | /// 85 | /// A delegate mapping to a number of source offsets (one-to-one if 86 | /// ). 87 | /// 88 | /// The same instance for chaining. 89 | public TargetBuilder AsStream( 90 | IConsumer consumer, 91 | out ISourceBlock> source, 92 | Action, TopicPartitionOffset>? handler = null, 93 | DataflowBlockOptions? options = null, 94 | Func? mapping = null) 95 | { 96 | if (this.consumerMetadata != null) 97 | { 98 | throw new InvalidOperationException("Already configured as a stream!"); 99 | } 100 | 101 | var loader = new MessageLoader(consumer ?? throw new ArgumentNullException(nameof(consumer))); 102 | loader.OnConsumed += handler; 103 | 104 | var offsets = new ConcurrentQueue(); 105 | loader.OnConsumed += (_, tpo) => offsets.Enqueue(tpo); 106 | this.offsetSource = offsets; 107 | 108 | source = new CustomBlock>(loader.Load, options ?? new()); 109 | 110 | this.offsetMapping = mapping; 111 | this.consumerMetadata = consumer.ConsumerGroupMetadata; 112 | this.commitTransactor = new OffsetTransactor(consumer); 113 | 114 | return this; 115 | } 116 | 117 | /// 118 | /// Builds the configured target block. 119 | /// 120 | /// 121 | /// A delegate invoked for each item delivered to Kafka, with its corresponding offests. 122 | /// 123 | /// Block options for sending data. 124 | /// The interval between transactions (5 seconds if ). 125 | /// The built target instance. 126 | public ITargetBlock Build( 127 | Action>? handler = null, 128 | DataflowBlockOptions? options = null, 129 | TimeSpan? interval = null) 130 | { 131 | ITransactor transactor; 132 | 133 | if (this.producerHandle != null) 134 | { 135 | var producer = new DependentProducerBuilder(this.producerHandle.Handle).Build(); 136 | transactor = new MessageTransactor(producer, this.messageSenders, this.consumerMetadata); 137 | } 138 | else 139 | { 140 | transactor = this.commitTransactor ?? throw new InvalidOperationException("No data mapped to T!"); 141 | } 142 | 143 | var flusher = new TransactionFlusher( 144 | transactor, 145 | interval ?? TimeSpan.FromSeconds(5), 146 | this.offsetSource, 147 | this.offsetMapping); 148 | 149 | flusher.OnDelivered += handler; 150 | 151 | return new CustomBlock(flusher.Flush, options ?? new()); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Kyle McClellan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kafka Dataflow 2 | An extension of [Confluent's Kafka client](https://github.com/confluentinc/confluent-kafka-dotnet) for use with `System.Threading.Tasks.Dataflow`. 3 | 4 | ## Features 5 | * Represent consumers/producers as dataflow blocks. 6 | * Process Kafka messages using a dataflow pipeline. 7 | * Configure transactional producing and EoS stream processing. 8 | 9 | ## Installation 10 | 11 | Add the NuGet package to your project: 12 | 13 | $ dotnet add package Confluent.Kafka.Dataflow 14 | 15 | ## Usage 16 | 17 | ### Consuming using `ISourceBlock` 18 | 19 | Use `IConsumer.AsSourceBlock(...)` to initialize a Kafka message pipeline. 20 | 21 | ```c# 22 | using System; 23 | using System.Threading.Tasks; 24 | using System.Threading.Tasks.Dataflow; 25 | using Confluent.Kafka; 26 | using Confluent.Kafka.Dataflow; 27 | 28 | using var consumer = new ConsumerBuilder( 29 | new ConsumerConfig 30 | { 31 | BootstrapServers = "localhost:9092", 32 | GroupId = "example", 33 | }).Build(); 34 | 35 | consumer.Subscribe("my-topic"); 36 | 37 | // Define a target block to process Kafka messages. 38 | var processor = new ActionBlock>( 39 | message => Console.WriteLine($"Message received: {message.Timestamp}")); 40 | 41 | // Initialize source and link to target. 42 | var blockOptions = new DataflowBlockOptions 43 | { 44 | // It's a good idea to limit buffered messages (in case processing falls behind). 45 | // Otherwise, all messages are offered as soon as they are available. 46 | BoundedCapacity = 8, 47 | }; 48 | 49 | var source = consumer.AsSourceBlock(options: blockOptions); 50 | source.LinkTo(processor, new DataflowLinkOptions { PropagateCompletion = true }); 51 | 52 | // Optionally, request to stop processing. 53 | await Task.Delay(10000); 54 | source.Complete(); 55 | 56 | // Wait for processing to finish. 57 | await processor.Completion; 58 | consumer.Close(); 59 | ``` 60 | 61 | ### Committing message offsets 62 | 63 | The Kafka client auto-commits periodically by default. It can automatically store/queue messages for the next commit as soon as they are loaded into memory. 64 | 65 | Alternatively, you can set `enable.auto.offset.store` to `false` and store offsets manually after processing is finished. This prevents unprocessed messages from being committed in exceptional scenarios. 66 | 67 | ```c# 68 | 69 | // Use a transform block to emit processed messages. 70 | var processor = new TransformBlock, Message>( 71 | async message => 72 | { 73 | // Process message asynchronously. 74 | // ... 75 | return message; 76 | }, 77 | new ExecutionDataflowBlockOptions 78 | { 79 | // Parallelism is OK as long as order is preserved. 80 | MaxDegreeOfParallelism = 8, 81 | EnsureOrdered = true, 82 | }); 83 | 84 | // Link the processor to the source and commit target. 85 | var source = consumer.AsSourceBlock(out commitTarget, options: blockOptions); 86 | 87 | var linkOptions = new DataflowLinkOptions { PropagateCompletion = true }; 88 | source.LinkTo(processor, linkOptions); 89 | processor.LinkTo(commitTarget, linkOptions); 90 | ``` 91 | 92 | ### Producing using `ITargetBlock` 93 | 94 | Use `IProducer.AsTargetBlock(...)` to direct a message pipeline into a destination Kafka topic: 95 | 96 | ```c# 97 | using System.Threading.Tasks.Dataflow; 98 | using Confluent.Kafka; 99 | using Confluent.Kafka.Dataflow; 100 | 101 | using var producer = new ProducerBuilder( 102 | new ProducerConfig 103 | { 104 | BootstrapServers = "localhost:9092", 105 | }).Build(); 106 | 107 | var target = producer.AsTargetBlock(new TopicPartition("my-topic", Partition.Any)); 108 | 109 | var generator = new TransformBlock>( 110 | i => new Message 111 | { 112 | Key = i.ToString(), 113 | Value = $"Value #{i}" 114 | }); 115 | 116 | generator.LinkTo(target, new DataflowLinkOptions { PropagateCompletion = true }); 117 | 118 | for (var i = 0; i < 10; i++) 119 | { 120 | generator.Post(i); 121 | } 122 | 123 | generator.Complete(); 124 | await target.Completion; 125 | ``` 126 | 127 | ### Transactions and stream processing using `TargetBuilder` 128 | 129 | Kafka supports producing messages and committing offsets in transactions. A common use case is for "stream processing," so a processed message offset is committed atomically with the corresponding messages it produced ("exactly once semantics"). 130 | 131 | Representing all data you want included within the same transaction as a custom data type, you can use `TargetBuilder` to create a single composite target: 132 | 133 | ```c# 134 | 135 | // Payment and shipping requests should be created transactionally. 136 | class OrderData 137 | { 138 | public OrderData(PaymentRequest payment, ShippingRequest shipping) 139 | { 140 | this.Payment = payment; 141 | this.Shipping = shipping; 142 | } 143 | 144 | public PaymentRequest Payment { get; } 145 | 146 | public ShippingRequest Shipping { get; } 147 | } 148 | 149 | // Configure mappings to Kafka messages to be produced. 150 | var builder = new TargetBuilder() 151 | .WithMessages( 152 | myProducer, 153 | order => new[] { new Message { Value = order.Payment } }, 154 | new TopicPartition("payment-requests", Partition.Any)) 155 | .WithMessages( 156 | myProducer, 157 | order => new[] { new Message { Value = order.Shipping } }, 158 | new TopicPartition("shipping-requests", Partition.Any)); 159 | 160 | // Optionally, link a Kafka source to include the processed offset for each order. 161 | // Payment and shipping will be created if and only if the order is committed. 162 | builder.AsStream(myConsumer, out var orderSource) 163 | var orderProcessor = new TransformBlock(this.ProcessOrder); 164 | orderSource.LinkTo(orderProcessor, LinkOptions); 165 | 166 | // Finish linking Kafka stream. 167 | var orderTarget = builder.Build(); 168 | orderProcessor.LinkTo(orderTarget, LinkOptions); 169 | 170 | await orderTarget.Completion; 171 | ``` 172 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "7.0.201", 4 | "rollForward": "minor" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmcclellan/kafka-dataflow/c8581ac389be57256ac6b8e0c8af7f3d1aed77ae/icon.png -------------------------------------------------------------------------------- /kafka-dataflow.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.5.33424.131 4 | MinimumVisualStudioVersion = 15.0.26124.0 5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Confluent.Kafka.Dataflow", "Confluent.Kafka.Dataflow\Confluent.Kafka.Dataflow.csproj", "{0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8BB24514-262B-4FD0-B6BC-86E578FAE33E}" 8 | ProjectSection(SolutionItems) = preProject 9 | .editorconfig = .editorconfig 10 | .gitignore = .gitignore 11 | global.json = global.json 12 | icon.png = icon.png 13 | LICENSE = LICENSE 14 | .github\workflows\publish.yml = .github\workflows\publish.yml 15 | README.md = README.md 16 | EndProjectSection 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Debug|x64 = Debug|x64 22 | Debug|x86 = Debug|x86 23 | Release|Any CPU = Release|Any CPU 24 | Release|x64 = Release|x64 25 | Release|x86 = Release|x86 26 | EndGlobalSection 27 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 28 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Debug|x64.ActiveCfg = Debug|Any CPU 31 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Debug|x64.Build.0 = Debug|Any CPU 32 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Debug|x86.ActiveCfg = Debug|Any CPU 33 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Debug|x86.Build.0 = Debug|Any CPU 34 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Release|x64.ActiveCfg = Release|Any CPU 37 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Release|x64.Build.0 = Release|Any CPU 38 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Release|x86.ActiveCfg = Release|Any CPU 39 | {0BD9D153-C8FA-4C88-9ADD-A5445482BD1E}.Release|x86.Build.0 = Release|Any CPU 40 | EndGlobalSection 41 | GlobalSection(SolutionProperties) = preSolution 42 | HideSolutionNode = FALSE 43 | EndGlobalSection 44 | GlobalSection(ExtensibilityGlobals) = postSolution 45 | SolutionGuid = {79B16889-9DFE-4D2D-834A-5A0A244B3CC9} 46 | EndGlobalSection 47 | EndGlobal 48 | --------------------------------------------------------------------------------