Pipeline Concepts
DataflowBuilder acts as a high-level, type-safe orchestrator built on top of the .NET Task Parallel Library (TPL) Dataflow (System.Threading.Tasks.Dataflow). This document explores the architectural concepts and mechanics governing how data travels through a pipeline.
1. The Producer-Consumer Model
In classical multithreading, coordinating worker queues, lock contention, backpressure, and graceful shutdown requires extensive boilerplate. TPL Dataflow replaces this with actor-like message-passing blocks.
DataflowBuilder encapsulates this model into a linear or multi-source pipeline:
flowchart LR
A[Entry BufferBlock] --> B[TransformBlock]
B --> C[BatchBlock]
C --> D[TransformBlock / Worker Pool]
D --> E[ActionBlock / Target]
Each stage operates concurrently on its own schedule, receiving messages from upstream and pushing processed messages downstream.
2. Block Categories
DataflowBuilder divides blocks into three roles:
| Category | Underlying Block Type | Purpose | Methods |
|---|---|---|---|
| Source Block | BufferBlock<T> / JoinBlock<T1, T2> |
Accepts external inputs into the pipeline with bounded queuing | FromSource<T>(), FromSources<T1, T2>() |
| Propagator Block | TransformBlock<TIn, TOut>, TransformManyBlock<TIn, TOut>, BatchBlock<T>, TimeBatchBlock<T>, GroupBlock<TKey, T> |
Transforms, filters, expands, batches, or groups intermediate items | Process, ProcessAsync, ProcessMany, ProcessManyAsync, Batch, Group |
| Target Block | ActionBlock<T> |
Final consumer that terminates the pipeline | ToTarget, ToTargetAsync |
3. Fluent State Machine & Compile-Time Type Safety
DataflowBuilder uses the marker pattern via IFluentBuilder<TExtension, TContext> to enforce valid pipeline sequencing at compile time:
- At Source: The builder exposes
ISourceBlock<TSource>, allowing only intermediate operations or a terminal sink. - Intermediate Stages: Each
.Process(...)or.Batch(...)returns anIFluentBuilder<ISourceBlock<TNext>, ...>. - At Target: Calling
.ToTarget(...)changes the builder marker toIFluentBuilder<ITargetBlock<TInput>, ...>. - Final Step: The
.Build()extension method is only accessible once the target block is specified. You cannot call.Build()midway or add further transformations after a terminal target.
// Compile error: Cannot call Process after ToTarget
DataFlowPipelineBuilder.FromSource<int>()
.ToTarget(Console.WriteLine)
.Process(x => x * 2); // DOES NOT COMPILE
// Compile error: Cannot call Build without a Target
DataFlowPipelineBuilder.FromSource<int>()
.Process(x => x * 2)
.Build(); // DOES NOT COMPILE
4. Completion Propagation
When CompleteAsync() is called on an IPipeline<T>:
- Completion is signaled to the pipeline's entry
BufferBlock. - As messages drain from the buffer, the
DataflowLinkOptions.PropagateCompletion = truelink triggers completion on the next downstream block. - Each block finishes processing its remaining buffered items before signaling its successor.
- When the terminal
ActionBlockcompletes,Task.WhenAll(source.Completion, target.Completion)resolves, confirming all stages have safely emptied and closed.
5. Grouping Operations
In addition to standard fixed-size batching (BatchBlock), DataflowBuilder provides GroupBlock<TGroupKey, TInput>:
- Items are classified into buckets using a key selector:
Func<TInput, TGroupKey>. - When any individual bucket reaches its
boundedCapacity, that specific bucket is immediately emitted downstream as an arrayTInput[]. - Unfinished groups continue to accumulate until either subsequent items fill them or the pipeline shuts down.
var pipeline = DataFlowPipelineBuilder.FromSource<SensorReading>()
// Group readings by SensorId; emit batches whenever any sensor accumulates 10 readings
.Group(reading => reading.SensorId, maxDegreeOfParallelism: 10)
.ToTarget(batch =>
{
Console.WriteLine($"Sensor {batch[0].SensorId} emitted {batch.Length} readings");
})
.Build();