Case: Batch Processing Pipeline
Problem
Design a batch ingestion pipeline: ingest 10M CSV rows/day from S3/Blob into a normalized DB. Each row goes through validate → enrich → transform → persist. Restartable; idempotent; scalable.
Walkthrough
Clarify
- 10M rows/day = ~115 rows/sec average; bursts to 1000+.
- Files arrive via Blob upload (uploaded once).
- Ingestion takes ~1-2h per file batch.
- Failure mid-pipeline must be recoverable.
- Each row independent (no cross-row order).
- DB is Postgres; transform-heavy.
Architecture
[Blob: file uploaded]
│
│ Event Grid → trigger
▼
[Orchestrator] (Worker / IHostedService)
│
├─→ Stream rows → Channel<Row>
│
└─→ Workers (N) read from Channel:
├── Validate (drop bad → DLQ)
├── Enrich (3rd-party API; cached)
├── Transform
└── Bulk-insert → DB
Checkpointing: every N rows, persist offset.
Restart: resume from offset.
Channel-based pipeline
public async Task ProcessAsync(string blobUri, CancellationToken ct)
{
var channel = Channel.CreateBounded<Row>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = true
});
// Producer
var producer = Task.Run(async () =>
{
await foreach (var row in StreamBlobRows(blobUri, ct))
await channel.Writer.WriteAsync(row, ct);
channel.Writer.Complete();
}, ct);
// Consumers (parallel)
var consumers = Enumerable.Range(0, 8).Select(_ => Task.Run(async () =>
{
var batch = new List<Row>(100);
await foreach (var row in channel.Reader.ReadAllAsync(ct))
{
if (!Validate(row)) continue;
row = await EnrichAsync(row, ct);
row = Transform(row);
batch.Add(row);
if (batch.Count >= 100)
{
await BulkInsertAsync(batch, ct);
await CheckpointAsync(row.Offset, ct);
batch.Clear();
}
}
if (batch.Count > 0) await BulkInsertAsync(batch, ct);
}, ct)).ToArray();
await Task.WhenAll(consumers.Append(producer));
}
Backpressure
BoundedChannelOptions with capacity 1000. Producer awaits when full → upstream throttled.
Idempotency
- Each row has natural key (
SourceFile + RowNumber). - DB insert:
INSERT ... ON CONFLICT (source_file, row_number) DO NOTHING. - Restart from checkpoint: rows already inserted skipped.
Checkpointing
Update every 100-1000 rows. Restart reads LastOffset.
Bulk insert
// EF Core 8+ ExecuteUpdateAsync / ExecuteInsertAsync, or use:
await using var conn = new NpgsqlConnection(...);
await conn.OpenAsync(ct);
using var writer = conn.BeginBinaryImport(@"COPY targets (a, b, c) FROM STDIN (FORMAT BINARY)");
foreach (var row in batch)
{
await writer.StartRowAsync(ct);
await writer.WriteAsync(row.A, ct);
await writer.WriteAsync(row.B, ct);
await writer.WriteAsync(row.C, ct);
}
await writer.CompleteAsync(ct);
COPY is 10-100x faster than per-row INSERT.
Dead letter
Bad rows → DLQ blob / table:
Manual review; potential reprocess.
Parallelism
- 8 consumer tasks default.
- Tune based on DB write capacity (don't overwhelm DB).
- CPU-bound transforms: scale by core count.
- I/O-bound enrichment: scale higher.
Enrichment with caching
public async Task<Row> EnrichAsync(Row row, CancellationToken ct)
{
var enriched = await _hybridCache.GetOrCreateAsync(
$"enrich:{row.Sku}",
(state, ct) => state.api.LookupAsync(state.sku, ct),
(api: _api, sku: row.Sku),
cancellationToken: ct);
return row with { Description = enriched.Description };
}
3rd-party API rate-limited → Polly resilience handler.
Throttling DB writes
private readonly SemaphoreSlim _dbSlots = new(4);
public async Task BulkInsertAsync(IList<Row> batch, CancellationToken ct)
{
await _dbSlots.WaitAsync(ct);
try { /* insert */ }
finally { _dbSlots.Release(); }
}
Max 4 concurrent inserts; protects DB.
Failure recovery
Worker crashes mid-batch:
- Channel buffer drains; remaining rows wait.
- Restart: reads LastOffset; resumes.
- Already-inserted rows skipped via UPSERT.
Observability
private static readonly Meter _m = new("Ingest");
private static readonly Counter<long> _rows = _m.CreateCounter<long>("ingest.rows");
private static readonly Histogram<double> _batchLatency = _m.CreateHistogram<double>("ingest.batch.latency.ms");
Track: - Rows/sec. - Bad-row rate. - Enrichment latency. - DB insert latency. - Memory / GC.
Hosting
- Azure Container Apps with KEDA scale on queue depth.
- AKS for advanced workflow (Argo Workflows, Dagster).
- Azure Functions Premium for event-trigger model.
- Aspire AppHost for local dev orchestration.
For 10M rows/day: a single ACA instance per file type often enough.
Bigger scale options
- Spark / Databricks: industry standard for huge data.
- Azure Data Factory: orchestration; managed.
- dbt: transforms in DB (post-load).
But for "10M rows/day" Channel-based .NET is fine.
Trade-offs
| Choice | Why | Trade-off |
|---|---|---|
| Channel | .NET-native; tight integration | Less mature than Spark |
| Bulk COPY | Fast | Postgres-specific |
| Checkpoint | Restartable | Storage |
| Cached enrichment | Fast | Stale risk |
What we'd skip
- Spark for 10M/day — Channel is fine.
- Microservices per stage — complexity not warranted.
- Kafka unless multi-consumer.