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ça | Uso |
|---|---|
Worker Service / BackgroundService | executar o batch |
IHostedService | hospedar o processo dentro da aplicação |
| SQL Server / PostgreSQL | persistir estado, checkpoints, erros e resultados |
| EF Core / Dapper | acesso ao banco |
| Quartz.NET / Hangfire | agendamento e disparo |
| Azure Service Bus / RabbitMQ / Kafka | filas, DLQ e retentativas assíncronas |
| OpenTelemetry | rastreamento, métricas e logs correlacionados |
| Polly | retry, timeout, circuit breaker, bulkhead |
| tabela de controle | restart seguro |
| chave idempotente | impedir 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:
- O job não processa tudo como uma única transação gigante.
- Ele divide o trabalho em chunks.
- Cada chunk tem estado próprio.
- Cada item processado tem chave idempotente.
- Erros recuperáveis são retentados.
- Erros de dado são isolados.
- 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:
| Volume | Chunk inicial sugerido |
|---|---|
| 100 mil registros | 500 a 2.000 |
| 1 milhão | 1.000 a 5.000 |
| 5 milhões | 2.000 a 10.000 |
| com API externa | menor, talvez 100 a 500 |
| com escrita pesada | menor |
| leitura simples + bulk insert | maior |
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
ExecuteUpdateAsyncquando possível - use bulk insert/update com biblioteca adequada
- use staging table
- use
SqlBulkCopyno SQL Server - evite um
SaveChangesAsyncpor 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 erro | Exemplo | Ação |
|---|---|---|
| Técnico transitório | timeout, rede, deadlock | retry |
| Técnico persistente | credencial inválida, schema quebrado | fail fast |
| Negócio recuperável | transação com status pendente | reprocessar depois |
| Negócio inválido | valor inconsistente, campo obrigatório ausente | skip + erro |
| Duplicidade | item já processado | ignorar via idempotência |
| Concorrência | outro worker pegou chunk | abandonar 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
| Item | Pergunta |
|---|---|
| Checkpoint | O job consegue continuar do último ponto consistente? |
| Idempotência | Reprocessar o mesmo item duplica efeito financeiro? |
| Índice único | O banco protege contra duplicidade? |
| Chunk | O processamento é dividido em blocos pequenos o suficiente? |
| Retry | Existe retry com limite e backoff? |
| Timeout | Cada chamada externa tem timeout explícito? |
| Skip | Um item inválido derruba o lote inteiro? |
| DLQ | Existe local para registros problemáticos? |
| Auditoria | Dá para provar o que foi processado? |
| Observabilidade | Dá para saber onde o batch parou? |
| Concorrência | Dois workers podem processar o mesmo chunk? |
| Reprocessamento | Existe tela/comando para reprocessar só falhas? |
| Cancelamento | O job respeita CancellationToken? |
| Deploy | O batch sobrevive a restart de container? |
| SLA | O 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