Skip to main content

batch/job crítico | Artigo | Arquitetura

  • batch/job crítico | Artigo | Arquitetura

Imagine um batch em .NET conciliando 5_000_000 de transações financeiras durante a madrugada.

Às 04:00, com 90% concluído, acontece uma falha:

  • erro de rede
  • timeout no banco
  • indisponibilidade temporária de API externa
  • registro inválido
  • deadlock
  • queda do processo
  • restart do container
  • falha no nó do cluster

A resposta fraca seria:

“Roda o job de novo do zero.”

Em fintech, isso é ruim porque pode gerar:

  • perda da janela operacional
  • duplicidade de lançamentos
  • divergência contábil
  • inconsistência entre sistemas
  • retrabalho manual
  • risco regulatório
  • atraso na abertura do banco/operação

A resposta correta é:

“O batch precisa ser reiniciável, idempotente, particionado por checkpoint, observável e tolerante a falhas parciais.”


Como pensar em batch crítico no .NET

No .NET, você normalmente monta isso com:

PeçaUso
Worker Service / BackgroundServiceexecutar o batch
IHostedServicehospedar o processo dentro da aplicação
SQL Server / PostgreSQLpersistir estado, checkpoints, erros e resultados
EF Core / Dapperacesso ao banco
Quartz.NET / Hangfireagendamento e disparo
Azure Service Bus / RabbitMQ / Kafkafilas, DLQ e retentativas assíncronas
OpenTelemetryrastreamento, métricas e logs correlacionados
Pollyretry, timeout, circuit breaker, bulkhead
tabela de controlerestart seguro
chave idempotenteimpedir duplicidade

Importante: Quartz e Hangfire ajudam no agendamento e execução, mas não resolvem automaticamente a semântica financeira do seu processamento. Checkpoint, idempotência, consistência e reconciliação ainda precisam ser parte do desenho da aplicação.


Arquitetura recomendada

+-----------------------+
| Scheduler |
| Quartz / Hangfire |
+----------+------------+
|
v
+-----------------------+
| Worker .NET |
| BackgroundService |
+----------+------------+
|
v
+-----------------------+
| BatchRun |
| estado da execução |
+----------+------------+
|
v
+-----------------------+
| BatchChunk |
| checkpoint por bloco |
+----------+------------+
|
v
+-----------------------+
| Processamento |
| conciliação financeira|
+----------+------------+
|
v
+-----------------------+
| Resultado |
| Ledger / conciliação |
+----------+------------+
|
+--------------------+
|
v
+---------------------+
| BatchItemError |
| erros / skip / DLQ |
+---------------------+

A ideia é simples:

  1. O job não processa tudo como uma única transação gigante.
  2. Ele divide o trabalho em chunks.
  3. Cada chunk tem estado próprio.
  4. Cada item processado tem chave idempotente.
  5. Erros recuperáveis são retentados.
  6. Erros de dado são isolados.
  7. O restart continua do último ponto consistente.

Modelo de estado do batch

Um batch crítico precisa de estado explícito.

BatchRun

Representa uma execução do job.

public enum BatchRunStatus
{
Created = 0,
Running = 1,
Completed = 2,
Failed = 3,
PartiallyCompleted = 4,
Cancelled = 5
}

public sealed class BatchRun
{
public Guid Id { get; set; }

public string JobName { get; set; } = default!;

public DateOnly ReferenceDate { get; set; }

public BatchRunStatus Status { get; set; }

public long TotalItems { get; set; }

public long ProcessedItems { get; set; }

public long FailedItems { get; set; }

public DateTimeOffset StartedAt { get; set; }

public DateTimeOffset? FinishedAt { get; set; }

public string? LastError { get; set; }
}

BatchChunk

Representa uma parte processável.

public enum BatchChunkStatus
{
Pending = 0,
Running = 1,
Completed = 2,
Failed = 3,
Skipped = 4
}

public sealed class BatchChunk
{
public Guid Id { get; set; }

public Guid BatchRunId { get; set; }

public long StartId { get; set; }

public long EndId { get; set; }

public BatchChunkStatus Status { get; set; }

public int AttemptCount { get; set; }

public DateTimeOffset? LockedUntil { get; set; }

public string? LockedBy { get; set; }

public DateTimeOffset? StartedAt { get; set; }

public DateTimeOffset? FinishedAt { get; set; }

public string? Error { get; set; }
}

BatchItemError

Registra erro de dado ou erro não fatal.

public sealed class BatchItemError
{
public Guid Id { get; set; }

public Guid BatchRunId { get; set; }

public Guid? BatchChunkId { get; set; }

public string TransactionId { get; set; } = default!;

public string ErrorType { get; set; } = default!;

public string ErrorMessage { get; set; } = default!;

public string PayloadJson { get; set; } = default!;

public DateTimeOffset CreatedAt { get; set; }
}

Checkpoint no .NET

Checkpoint é a garantia de que o batch consegue continuar sem reprocessar tudo.

Em vez de:

Processar 5 milhões
Commit no final

Você faz:

Processar chunk 1   -> commit
Processar chunk 2 -> commit
Processar chunk 3 -> commit
Falhou no chunk 4 -> restart a partir do chunk 4

Exemplo:

public sealed class ConciliacaoWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ConciliacaoWorker> _logger;

public ConciliacaoWorker(
IServiceScopeFactory scopeFactory,
ILogger<ConciliacaoWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromMinutes(1));

while (await timer.WaitForNextTickAsync(stoppingToken))
{
using IServiceScope scope = _scopeFactory.CreateScope();

var processor = scope.ServiceProvider
.GetRequiredService<ConciliacaoBatchProcessor>();

await processor.ProcessarAsync(stoppingToken);
}
}
}

Processador com restart por chunk

public sealed class ConciliacaoBatchProcessor
{
private readonly AppDbContext _db;
private readonly ILogger<ConciliacaoBatchProcessor> _logger;

public ConciliacaoBatchProcessor(
AppDbContext db,
ILogger<ConciliacaoBatchProcessor> logger)
{
_db = db;
_logger = logger;
}

public async Task ProcessarAsync(CancellationToken ct)
{
BatchChunk? chunk = await ObterProximoChunkAsync(ct);

if (chunk is null)
{
return;
}

try
{
await MarcarChunkComoRunningAsync(chunk, ct);

List<TransacaoFinanceira> transacoes = await _db.Transacoes
.Where(x => x.Id >= chunk.StartId && x.Id <= chunk.EndId)
.OrderBy(x => x.Id)
.ToListAsync(ct);

foreach (TransacaoFinanceira transacao in transacoes)
{
await ProcessarItemComSkipAsync(chunk, transacao, ct);
}

await MarcarChunkComoCompletoAsync(chunk, ct);
}
catch (Exception ex)
{
await MarcarChunkComoFalhoAsync(chunk, ex, ct);

_logger.LogError(
ex,
"Falha ao processar chunk {ChunkId} do batch {BatchRunId}",
chunk.Id,
chunk.BatchRunId);
}
}

private async Task<BatchChunk?> ObterProximoChunkAsync(CancellationToken ct)
{
return await _db.BatchChunks
.Where(x =>
x.Status == BatchChunkStatus.Pending ||
x.Status == BatchChunkStatus.Failed)
.Where(x => x.AttemptCount < 5)
.OrderBy(x => x.StartId)
.FirstOrDefaultAsync(ct);
}
}

Em produção, o método ObterProximoChunkAsync precisa considerar concorrência. Se vários workers estiverem ativos, você precisa de lock transacional, SELECT FOR UPDATE SKIP LOCKED, sp_getapplock, lease com LockedUntil, ou outra estratégia equivalente.


Idempotência

Idempotência significa que repetir o processamento da mesma transação não pode gerar efeito duplicado.

Esse ponto é essencial em sistemas com retry, restart ou entrega pelo menos uma vez. A literatura sobre sistemas distribuídos trata idempotência como propriedade obrigatória quando uma operação pode ser interrompida e reiniciada, porque a mesma mensagem ou operação pode ser processada mais de uma vez.

Exemplo ruim

await _db.LancamentosContabeis.AddAsync(new LancamentoContabil
{
TransacaoId = transacao.Id,
Valor = transacao.Valor,
Tipo = "Conciliacao"
}, ct);

await _db.SaveChangesAsync(ct);

Se o processo cair depois do SaveChangesAsync, mas antes de atualizar o checkpoint, o restart pode inserir o mesmo lançamento de novo.

Exemplo correto

Crie uma chave idempotente:

CONCILIACAO:{DataReferencia}:{TransacaoId}

E coloque índice único no banco.

public sealed class LancamentoContabil
{
public Guid Id { get; set; }

public string IdempotencyKey { get; set; } = default!;

public string TransacaoId { get; set; } = default!;

public decimal Valor { get; set; }

public DateTimeOffset CriadoEm { get; set; }
}

Configuração no EF Core:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LancamentoContabil>()
.HasIndex(x => x.IdempotencyKey)
.IsUnique();
}

Processamento idempotente:

private async Task ProcessarTransacaoAsync(
TransacaoFinanceira transacao,
DateOnly dataReferencia,
CancellationToken ct)
{
string idempotencyKey =
$"CONCILIACAO:{dataReferencia:yyyyMMdd}:{transacao.Id}";

bool jaProcessado = await _db.LancamentosContabeis
.AnyAsync(x => x.IdempotencyKey == idempotencyKey, ct);

if (jaProcessado)
{
return;
}

_db.LancamentosContabeis.Add(new LancamentoContabil
{
Id = Guid.NewGuid(),
IdempotencyKey = idempotencyKey,
TransacaoId = transacao.Id.ToString(),
Valor = transacao.Valor,
CriadoEm = DateTimeOffset.UtcNow
});

await _db.SaveChangesAsync(ct);
}

Melhor ainda: deixe o banco proteger a regra com índice único. A aplicação pode falhar, mas o banco não deve permitir duplicidade.


Transação por chunk

Um chunk precisa ter uma fronteira transacional clara.

private async Task ProcessarChunkAsync(
BatchChunk chunk,
List<TransacaoFinanceira> transacoes,
CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);

foreach (TransacaoFinanceira transacao in transacoes)
{
await ProcessarItemComSkipAsync(chunk, transacao, ct);
}

chunk.Status = BatchChunkStatus.Completed;
chunk.FinishedAt = DateTimeOffset.UtcNow;

await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}

Mas cuidado: transação grande demais pode gerar:

  • lock prolongado
  • pressão no log do banco
  • deadlock
  • timeout
  • degradação para outras aplicações

Por isso, em batch financeiro, o chunk size precisa ser calibrado.

Exemplo:

VolumeChunk inicial sugerido
100 mil registros500 a 2.000
1 milhão1.000 a 5.000
5 milhões2.000 a 10.000
com API externamenor, talvez 100 a 500
com escrita pesadamenor
leitura simples + bulk insertmaior

Skip Logic

Nem todo erro deve derrubar o batch.

Erros fatais

Devem parar o batch ou o chunk:

  • banco indisponível
  • schema incompatível
  • erro de autenticação
  • falha sistêmica em API obrigatória
  • timeout generalizado
  • erro de configuração
  • falta de permissão

Erros não fatais

Podem ser isolados:

  • CPF/CNPJ inválido
  • transação sem campo obrigatório
  • divergência de arredondamento
  • status desconhecido
  • registro duplicado recebido de origem
  • payload malformado de um item específico

Exemplo:

private async Task ProcessarItemComSkipAsync(
BatchChunk chunk,
TransacaoFinanceira transacao,
CancellationToken ct)
{
try
{
await ProcessarTransacaoAsync(
transacao,
DateOnly.FromDateTime(DateTime.UtcNow),
ct);
}
catch (DadoInvalidoException ex)
{
_db.BatchItemErrors.Add(new BatchItemError
{
Id = Guid.NewGuid(),
BatchRunId = chunk.BatchRunId,
BatchChunkId = chunk.Id,
TransactionId = transacao.Id.ToString(),
ErrorType = ex.GetType().Name,
ErrorMessage = ex.Message,
PayloadJson = JsonSerializer.Serialize(transacao),
CreatedAt = DateTimeOffset.UtcNow
});

await _db.SaveChangesAsync(ct);
}
}

A regra é:

erro de infraestrutura geralmente gera retry; erro de dado geralmente gera skip + tabela de erro + conciliação posterior.


Dead Letter em .NET

Quando o processamento usa fila, o padrão é enviar mensagens problemáticas para uma DLQ.

Azure Service Bus, por exemplo, possui suporte a dead-letter queues, message sessions, scheduled delivery e transações, recursos úteis para cenários de média/alta complexidade.

Exemplo conceitual:

try
{
await ProcessarMensagemAsync(message, ct);

await receiver.CompleteMessageAsync(message, ct);
}
catch (DadoInvalidoException ex)
{
await receiver.DeadLetterMessageAsync(
message,
deadLetterReason: "DADO_INVALIDO",
deadLetterErrorDescription: ex.Message,
cancellationToken: ct);
}
catch (Exception)
{
await receiver.AbandonMessageAsync(message, cancellationToken: ct);
}

Sem fila, você simula uma DLQ com tabela:

BatchItemError
BatchDeadLetter
BatchManualReview

Retry correto

Retry não é while true.

Retry precisa de:

  • limite de tentativas
  • backoff exponencial
  • jitter
  • timeout por tentativa
  • classificação de exceção
  • circuit breaker quando o destino está indisponível

Exemplo com Polly:

var retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt)));

await retryPolicy.ExecuteAsync(async () =>
{
await ChamarApiExternaAsync(ct);
});

Para operação financeira, retry só é seguro quando a operação remota também é idempotente.

Exemplo:

POST /pagamentos
Idempotency-Key: CONCILIACAO-20260522-123456

Sem idempotência no destino, retry pode duplicar efeito.


Padrão de tabela para controle operacional

Uma estrutura mínima:

CREATE TABLE batch_run (
id UUID PRIMARY KEY,
job_name VARCHAR(200) NOT NULL,
reference_date DATE NOT NULL,
status VARCHAR(50) NOT NULL,
total_items BIGINT NOT NULL DEFAULT 0,
processed_items BIGINT NOT NULL DEFAULT 0,
failed_items BIGINT NOT NULL DEFAULT 0,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP NULL,
last_error TEXT NULL
);

CREATE TABLE batch_chunk (
id UUID PRIMARY KEY,
batch_run_id UUID NOT NULL,
start_id BIGINT NOT NULL,
end_id BIGINT NOT NULL,
status VARCHAR(50) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
locked_until TIMESTAMP NULL,
locked_by VARCHAR(200) NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error TEXT NULL
);

CREATE TABLE batch_item_error (
id UUID PRIMARY KEY,
batch_run_id UUID NOT NULL,
batch_chunk_id UUID NULL,
transaction_id VARCHAR(100) NOT NULL,
error_type VARCHAR(200) NOT NULL,
error_message TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);

CREATE TABLE lancamento_contabil (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(300) NOT NULL UNIQUE,
transaction_id VARCHAR(100) NOT NULL,
valor NUMERIC(18, 2) NOT NULL,
criado_em TIMESTAMP NOT NULL
);

Controle de concorrência

Se você tiver vários workers processando chunks, precisa evitar que dois workers peguem o mesmo chunk.

Estratégia com lease

private async Task<BatchChunk?> ObterChunkComLeaseAsync(
string workerId,
CancellationToken ct)
{
DateTimeOffset agora = DateTimeOffset.UtcNow;
DateTimeOffset lockAte = agora.AddMinutes(5);

BatchChunk? chunk = await _db.BatchChunks
.Where(x =>
x.Status == BatchChunkStatus.Pending ||
x.Status == BatchChunkStatus.Failed)
.Where(x =>
x.LockedUntil == null ||
x.LockedUntil < agora)
.OrderBy(x => x.StartId)
.FirstOrDefaultAsync(ct);

if (chunk is null)
{
return null;
}

chunk.Status = BatchChunkStatus.Running;
chunk.LockedBy = workerId;
chunk.LockedUntil = lockAte;
chunk.AttemptCount++;

await _db.SaveChangesAsync(ct);

return chunk;
}

Em ambiente realmente concorrente, prefira lock atômico no banco. A ideia acima é didática; em produção, dois workers podem ler o mesmo chunk antes do SaveChangesAsync se você não controlar isolamento, rowversion, update condicional ou lock transacional.


Update condicional para lock seguro

Exemplo conceitual:

UPDATE batch_chunk
SET
status = 'Running',
locked_by = @workerId,
locked_until = @lockedUntil,
attempt_count = attempt_count + 1
WHERE id = @chunkId
AND status IN ('Pending', 'Failed')
AND (locked_until IS NULL OR locked_until < @now);

Depois você verifica:

if (affectedRows == 0)
{
// Outro worker pegou primeiro.
return null;
}

Bulk processing

Para 5 milhões de registros, cuidado com EF Core rastreando tudo em memória.

Evite:

var transacoes = await _db.Transacoes.ToListAsync(ct);

Prefira paginação por chave:

const int pageSize = 5000;

long ultimoId = 0;

while (true)
{
List<TransacaoFinanceira> transacoes = await _db.Transacoes
.AsNoTracking()
.Where(x => x.Id > ultimoId)
.OrderBy(x => x.Id)
.Take(pageSize)
.ToListAsync(ct);

if (transacoes.Count == 0)
{
break;
}

foreach (var transacao in transacoes)
{
await ProcessarTransacaoAsync(transacao, dataReferencia, ct);
ultimoId = transacao.Id;
}
}

Para escrita em massa:

  • use ExecuteUpdateAsync quando possível
  • use bulk insert/update com biblioteca adequada
  • use staging table
  • use SqlBulkCopy no SQL Server
  • evite um SaveChangesAsync por item quando o volume for alto

Observabilidade

Batch crítico sem observabilidade é loteria.

Você precisa saber:

  • quantos itens foram processados
  • quantos falharam
  • qual chunk está rodando
  • qual chunk travou
  • tempo médio por chunk
  • percentil de duração
  • taxa de erro por tipo
  • último checkpoint persistido
  • quantidade de skips
  • quantidade de retries
  • tempo restante estimado
  • worker responsável
  • trace de chamadas externas

Em .NET, tracing usa System.Diagnostics.Activity; um span representa uma operação como request HTTP, chamada ao banco, I/O caro ou uma etapa interna. O contexto do span permite correlação entre traces, logs e métricas.

Para batches, um detalhe importante: nem sempre a relação pai-filho de traces é suficiente. Para cenários de batching, links entre spans podem ser usados quando a relação direta não representa bem o fluxo.

Exemplo:

public static class BatchTelemetry
{
public static readonly ActivitySource ActivitySource =
new("Financeiro.Conciliacao.Batch");

public static readonly Meter Meter =
new("Financeiro.Conciliacao.Batch");

public static readonly Counter<long> ItensProcessados =
Meter.CreateCounter<long>("batch_itens_processados_total");

public static readonly Counter<long> ItensComErro =
Meter.CreateCounter<long>("batch_itens_erro_total");

public static readonly Histogram<double> DuracaoChunk =
Meter.CreateHistogram<double>("batch_chunk_duracao_ms");
}

Uso:

using Activity? activity = BatchTelemetry.ActivitySource
.StartActivity("processar_chunk");

activity?.SetTag("batch.run_id", chunk.BatchRunId);
activity?.SetTag("batch.chunk_id", chunk.Id);
activity?.SetTag("batch.start_id", chunk.StartId);
activity?.SetTag("batch.end_id", chunk.EndId);

Stopwatch sw = Stopwatch.StartNew();

try
{
await ProcessarChunkAsync(chunk, transacoes, ct);

BatchTelemetry.ItensProcessados.Add(transacoes.Count);

activity?.SetStatus(ActivityStatusCode.Ok);
}
catch (Exception ex)
{
BatchTelemetry.ItensComErro.Add(1);

activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);

throw;
}
finally
{
sw.Stop();
BatchTelemetry.DuracaoChunk.Record(sw.Elapsed.TotalMilliseconds);
}

Logs úteis

Log ruim:

_logger.LogError(ex, "Erro no batch");

Log útil:

_logger.LogError(
ex,
"Erro ao processar chunk {ChunkId} do batch {BatchRunId}. Range {StartId}-{EndId}. Tentativa {AttemptCount}",
chunk.Id,
chunk.BatchRunId,
chunk.StartId,
chunk.EndId,
chunk.AttemptCount);

Para item:

_logger.LogWarning(
"Transação inválida ignorada. BatchRunId={BatchRunId}, ChunkId={ChunkId}, TransacaoId={TransacaoId}, Motivo={Motivo}",
chunk.BatchRunId,
chunk.Id,
transacao.Id,
ex.Message);

Diferença entre erro técnico e erro de negócio

Tipo de erroExemploAção
Técnico transitóriotimeout, rede, deadlockretry
Técnico persistentecredencial inválida, schema quebradofail fast
Negócio recuperáveltransação com status pendentereprocessar depois
Negócio inválidovalor inconsistente, campo obrigatório ausenteskip + erro
Duplicidadeitem já processadoignorar via idempotência
Concorrênciaoutro worker pegou chunkabandonar e buscar outro

Fluxo correto de execução

1. Criar BatchRun
2. Descobrir universo de transações
3. Gerar BatchChunks
4. Worker pega próximo chunk pendente
5. Worker marca chunk como Running
6. Worker processa itens
7. Cada item gera efeito idempotente
8. Erros de dado vão para BatchItemError
9. Chunk é marcado como Completed
10. Métricas/logs/traces são enviados
11. Se falhar, chunk fica Failed
12. Restart pega apenas chunks Pending/Failed

Exemplo de resposta forte em entrevista

Eu não reiniciaria o job do zero. Eu desenharia o batch com checkpoint por chunk, persistindo metadados da execução numa tabela de controle. Cada chunk teria estado próprio, tentativa, lock/lease e range de processamento. O processamento de cada transação seria idempotente usando uma chave única de negócio, como CONCILIACAO:{data}:{transactionId}, protegida por índice único no banco. Erros técnicos teriam retry com backoff e limite de tentativas. Erros de dado seriam isolados em uma tabela de erro ou DLQ para tratamento manual, sem derrubar os demais milhões de registros. No restart, o worker buscaria apenas chunks pendentes ou falhos, sem duplicar lançamentos já commitados. Além disso, eu instrumentaria o batch com logs estruturados, métricas e tracing para saber progresso, taxa de erro, duração por chunk e último checkpoint consistente.


Checklist de produção

ItemPergunta
CheckpointO job consegue continuar do último ponto consistente?
IdempotênciaReprocessar o mesmo item duplica efeito financeiro?
Índice únicoO banco protege contra duplicidade?
ChunkO processamento é dividido em blocos pequenos o suficiente?
RetryExiste retry com limite e backoff?
TimeoutCada chamada externa tem timeout explícito?
SkipUm item inválido derruba o lote inteiro?
DLQExiste local para registros problemáticos?
AuditoriaDá para provar o que foi processado?
ObservabilidadeDá para saber onde o batch parou?
ConcorrênciaDois workers podem processar o mesmo chunk?
ReprocessamentoExiste tela/comando para reprocessar só falhas?
CancelamentoO job respeita CancellationToken?
DeployO batch sobrevive a restart de container?
SLAO tempo restante estimado é conhecido?

O que evitar

- Uma transação gigante para milhões de registros
- Commit apenas no final
- Reprocessar tudo do zero
- SaveChanges por item sem necessidade
- Não ter índice único de idempotência
- Retry infinito
- Catch genérico que esconde erro
- Ignorar CancellationToken
- Processar tudo em memória
- Não ter tabela de erro
- Não ter métrica de progresso
- Misturar erro de dado com erro técnico

Implementação mínima recomendada

Para um projeto .NET real, eu começaria com:

src/
Financeiro.Batch.Worker/
ConciliacaoWorker.cs
ConciliacaoBatchProcessor.cs
ConciliacaoChunkProcessor.cs

Financeiro.Batch.Domain/
BatchRun.cs
BatchChunk.cs
BatchItemError.cs
LancamentoContabil.cs

Financeiro.Batch.Infrastructure/
AppDbContext.cs
Repositories/
Telemetry/
Resilience/

Financeiro.Batch.Contracts/
TransacaoFinanceira.cs
ResultadoConciliacao.cs

E as tabelas:

batch_run
batch_chunk
batch_item_error
lancamento_contabil

Com os índices:

CREATE UNIQUE INDEX ux_lancamento_idempotency_key
ON lancamento_contabil (idempotency_key);

CREATE INDEX ix_batch_chunk_status_start_id
ON batch_chunk (status, start_id);

CREATE INDEX ix_batch_item_error_batch_run_id
ON batch_item_error (batch_run_id);

Versão resumida

Batch financeiro crítico em .NET não deve ser "tudo ou nada".

Deve ter:

1. Checkpoint por chunk
2. Restart a partir do último ponto consistente
3. Idempotência com chave única
4. Skip logic para dado inválido
5. DLQ ou tabela de erro
6. Retry controlado para falha transitória
7. Lock/lease para múltiplos workers
8. Observabilidade com logs, métricas e tracing
9. Auditoria operacional
10. Reprocessamento seletivo