From 6e23be4c71056317b1d3ff29a323c26643322a48 Mon Sep 17 00:00:00 2001 From: net0well Date: Tue, 28 Jul 2026 22:54:39 -0300 Subject: [PATCH] =?UTF-8?q?feat(infra):=20nova=20tentativa=20no=20object?= =?UTF-8?q?=20storage=20para=20n=C3=A3o=20queimar=20cota=20da=20SEFAZ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Falha ao arquivar aborta o lote inteiro, o ponteiro de NSU não avança, e a rodada seguinte busca os mesmos documentos na SEFAZ de novo. A consulta é o recurso racionado; a gravação no MinIO não custa nada. Um soluço de rede de meio segundo custava uma ida à SEFAZ. O pipeline repete até três vezes, com espera exponencial e jitter, e limita cada tentativa em 30 segundos. O jitter importa porque a sincronização roda em paralelo por empresa: sem ele, as que falharem juntas voltariam juntas e a segunda tentativa recriaria a rajada. Sem disjuntor, de propósito. Ele seria por processo e em memória, enquanto o que precisa ser contido já é contido no lugar certo: o bloqueio por empresa vive no banco, sobrevive a reinício e vale para todas as instâncias. Um disjuntor sobre "o MinIO" ainda pararia a captura de todas as empresas por causa de uma, sem nada em troca — se o storage caiu de vez, o job já falha rápido e o ciclo seguinte retoma. Pela mesma razão nada foi acrescentado à SEFAZ: 656 não é falha transitória, e repetir aprofunda o bloqueio. Classificação por lista de permissão Quase toda exceção do SDK herda de MinioException, inclusive ObjectNotFoundException, AccessDeniedException e InvalidBucketNameException. Um Handle genérico repetiria arquivo inexistente e credencial errada três vezes para chegar ao mesmo lugar. Repete-se apenas o que se sabe transitório; o resto é definitivo. A lista de permissão também envelhece melhor: exceção nova do SDK entra como definitiva, que é o padrão seguro. Um stream por tentativa O MemoryStream é criado dentro do pipeline. Reaproveitá-lo faria a segunda tentativa encontrá-lo no fim e gravar um objeto de zero byte — sem erro nenhum, porque do ponto de vista do SDK o envio deu certo, e o XML sumiria em silêncio. Repetir é seguro porque a operação é idempotente: o mesmo nome com o mesmo conteúdo produz o mesmo objeto, e o XML autorizado nunca muda. --- Directory.Packages.props | 1 + README.md | 1 + RUNBOOK.md | 13 ++ ...fraestruturaServiceCollectionExtensions.cs | 39 ++++ .../Resiliencia/FalhaTransitoriaDeStorage.cs | 42 ++++ .../Storage/MinioXmlStorage.cs | 112 +++++++--- .../eContabil.Infrastructure.csproj | 1 + .../FalhaTransitoriaDeStorageTests.cs | 80 ++++++++ .../Storage/MinioFixture.cs | 23 +++ .../MinioXmlStorageResilienciaTests.cs | 194 ++++++++++++++++++ .../Storage/MinioXmlStorageTests.cs | 22 +- 11 files changed, 483 insertions(+), 45 deletions(-) create mode 100644 src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs create mode 100644 tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs create mode 100644 tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bb2b9b1..9c27017 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,7 @@ + diff --git a/README.md b/README.md index 4e91df3..e7c1052 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ auditável de tudo que foi emitido contra ou pelos seus clientes. | Banco | PostgreSQL 17 · EF Core 10 (Npgsql) | | Storage | MinIO (S3-compatible) | | Agendamento | Hangfire | +| Resiliência | Polly 8 (nova tentativa no object storage) | | Fiscal | ZeusFiscal (`Hercules.NET.NFe.NFCe`) | | Frontend | Angular · Signals · NgRx SignalStore · Angular Material | | Infra | Docker + Docker Compose | diff --git a/RUNBOOK.md b/RUNBOOK.md index 0620e39..7618d8e 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -148,6 +148,19 @@ AGENDAMENTO_ATIVO=false docker compose up -d --build api A API atende normalmente (grade, relatórios, download), só não roda job nenhum. Para religar, suba de novo sem a variável: `docker compose up -d api`. +### Falha ao arquivar no object storage + +O arquivamento repete sozinho até três vezes, com espera crescente e jitter, quando a falha é de rede ou +o MinIO responde 5xx. Erro permanente — arquivo inexistente, bucket errado, credencial inválida — falha +na primeira tentativa, porque insistir só atrasaria o lote para chegar ao mesmo lugar. + +A repetição existe por causa da SEFAZ, não do MinIO: falha ao arquivar aborta o lote, o ponteiro de NSU +não avança, e a rodada seguinte busca os mesmos documentos na SEFAZ de novo. A consulta é o recurso +racionado. + +Se `storage.indisponivel` aparecer no log da sincronização, as quatro tentativas já se esgotaram — o +MinIO está fora, não oscilando. Confira `/health/ready` antes de mexer no job. + ### "A nota não tem itens, CFOP nem impostos na tela" O documento foi capturado antes de a extração existir: o XML está arquivado, mas nada foi lido dele. diff --git a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs index 0782b49..6929ebc 100644 --- a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs +++ b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ using eContabil.Infrastructure.Persistencia; using eContabil.Infrastructure.Persistencia.Consultas; using eContabil.Infrastructure.Persistencia.Repositorios; +using eContabil.Infrastructure.Resiliencia; using eContabil.Infrastructure.Sefaz; using eContabil.Infrastructure.Seguranca; using eContabil.Infrastructure.Storage; @@ -31,9 +32,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Minio; +using Polly; +using Polly.Retry; namespace Microsoft.Extensions.DependencyInjection; @@ -112,6 +116,41 @@ public static IServiceCollection AddArmazenamentoESeguranca( .Build(); }); + // Nova tentativa curta no object storage. + // + // Não é resiliência genérica: é economia de cota da SEFAZ. Uma falha ao arquivar aborta o lote + // inteiro, o ponteiro de NSU não avança, e a rodada seguinte busca **os mesmos documentos na + // SEFAZ de novo** — a consulta é o recurso racionado, a gravação no MinIO não custa nada. Um + // soluço de rede de meio segundo não pode custar uma ida à SEFAZ. + // + // Sem disjuntor de propósito. Ele é por processo e em memória, enquanto o que precisa ser + // contido aqui já é contido no lugar certo: o bloqueio por empresa, que vive no banco, sobrevive + // a reinício e vale para todas as instâncias. Um disjuntor sobre "o MinIO" ainda pararia a + // captura de todas as empresas por causa de uma, sem nada em troca — se o storage caiu de vez, o + // job já falha rápido e o ciclo seguinte retoma. + services.AddResiliencePipeline(FalhaTransitoriaDeStorage.Pipeline, (construtor, contexto) => + { + construtor + .ConfigureTelemetry(contexto.ServiceProvider.GetRequiredService()) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + BackoffType = DelayBackoffType.Exponential, + + // Jitter porque a sincronização roda em paralelo por empresa: sem ele, as que + // falharem juntas voltariam juntas, e a segunda tentativa recriaria a rajada. + UseJitter = true, + Delay = TimeSpan.FromMilliseconds(200), + ShouldHandle = argumentos => ValueTask.FromResult( + argumentos.Outcome.Exception is { } falha + && FalhaTransitoriaDeStorage.Ehtransitoria(falha)) + }) + + // Mais interno: vale por tentativa. Sem ele, uma conexão pendurada seguraria o worker + // até o tempo limite do próprio job, e as demais empresas ficariam esperando atrás. + .AddTimeout(TimeSpan.FromSeconds(30)); + }); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs b/src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs new file mode 100644 index 0000000..b945131 --- /dev/null +++ b/src/eContabil.Infrastructure/Resiliencia/FalhaTransitoriaDeStorage.cs @@ -0,0 +1,42 @@ +using System.Net.Sockets; +using Minio.Exceptions; +using Polly.Timeout; + +namespace eContabil.Infrastructure.Resiliencia; + +/// +/// Decide se uma falha do object storage merece nova tentativa. +/// +/// +/// A regra é de permissão, não de exclusão: repete só o que se sabe transitório, e trata todo o +/// resto como definitivo. O SDK do MinIO faz quase toda exceção herdar de — +/// inclusive ObjectNotFoundException, AccessDeniedException e InvalidBucketNameException. +/// Um Handle<MinioException> genérico repetiria arquivo inexistente e credencial errada, +/// gastando tempo do job para chegar ao mesmo lugar. +/// +/// A lista de permissão também envelhece melhor: uma exceção nova do SDK entra como definitiva, que é o +/// comportamento seguro. Numa lista de exclusão, ela entraria como repetível sem ninguém decidir isso. +/// +public static class FalhaTransitoriaDeStorage +{ + /// Nome do pipeline registrado na injeção de dependência. + public const string Pipeline = "storage-xml"; + + public static bool Ehtransitoria(Exception excecao) => excecao switch + { + // Cancelamento é decisão de quem chamou, nunca falha do storage. + OperationCanceledException => false, + + // O MinIO não respondeu, respondeu 5xx ou cortou a resposta no meio. + ConnectionException or InternalServerException or UnexpectedMinioException + or UnexpectedShortReadException => true, + + // Rede abaixo do SDK: conexão recusada, reset, DNS momentaneamente fora. + HttpRequestException or SocketException or IOException => true, + + // Estouro do limite por tentativa imposto pelo próprio pipeline. + TimeoutRejectedException => true, + + _ => false + }; +} diff --git a/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs b/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs index 675cc16..1bcff63 100644 --- a/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs +++ b/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs @@ -3,11 +3,15 @@ using eContabil.Application.Armazenamento; using eContabil.Domain.ValueObjects; using eContabil.Infrastructure.Compactacao; +using eContabil.Infrastructure.Resiliencia; using eContabil.Shared; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Minio; using Minio.DataModel.Args; using Minio.Exceptions; +using Polly; +using Polly.Registry; namespace eContabil.Infrastructure.Storage; @@ -17,10 +21,29 @@ namespace eContabil.Infrastructure.Storage; /// /// O nome vem da chave, e não do identificador da empresa, porque é isso que permite **um único objeto** /// quando a mesma NF-e interessa a duas empresas do escritório. As duas apontam para ele. +/// +/// Toda chamada ao SDK passa por um pipeline de nova tentativa. As operações usadas aqui são todas +/// idempotentes — gravar o mesmo objeto com o mesmo conteúdo, ler, conferir existência —, então repetir +/// não duplica nem corrompe nada. /// -public sealed class MinioXmlStorage(IMinioClient cliente, IOptions opcoes) : IXmlStorage +public sealed class MinioXmlStorage : IXmlStorage { - private readonly MinioOptions _opcoes = opcoes?.Value ?? throw new ArgumentNullException(nameof(opcoes)); + private readonly IMinioClient _cliente; + private readonly MinioOptions _opcoes; + private readonly ResiliencePipeline _tentativas; + + public MinioXmlStorage( + IMinioClient cliente, + IOptions opcoes, + ResiliencePipelineProvider pipelines) + { + ArgumentNullException.ThrowIfNull(opcoes); + ArgumentNullException.ThrowIfNull(pipelines); + + _cliente = cliente; + _opcoes = opcoes.Value; + _tentativas = pipelines.GetPipeline(FalhaTransitoriaDeStorage.Pipeline); + } public Task> SalvarAsync( string chaveAcesso, Stream xml, CancellationToken ct) => @@ -64,16 +87,7 @@ private async Task> GravarAsync( _opcoes.BucketXmls, objectName, hash, conteudo.Length, JaExistia: true)); } - using var origem = new MemoryStream(conteudo); - - await cliente.PutObjectAsync( - new PutObjectArgs() - .WithBucket(_opcoes.BucketXmls) - .WithObject(objectName) - .WithStreamData(origem) - .WithObjectSize(conteudo.Length) - .WithContentType("application/xml"), - ct); + await EnviarAsync(_opcoes.BucketXmls, objectName, conteudo, ct); return Result.Ok(new ObjetoArmazenadoDto( _opcoes.BucketXmls, objectName, hash, conteudo.Length, JaExistia: false)); @@ -98,18 +112,27 @@ public async Task> ObterAsync(string bucket, string objectName, C { try { - // O conteúdo é copiado para um buffer porque o SDK entrega o corpo por callback e fecha a - // conexão ao final da chamada. XML de NF-e tem dezenas de KB — o custo é irrelevante. - var destino = new MemoryStream(); - - await cliente.GetObjectAsync( - new GetObjectArgs() - .WithBucket(bucket) - .WithObject(objectName) - .WithCallbackStream((fluxo, cancelamento) => fluxo.CopyToAsync(destino, cancelamento)), + // O buffer nasce dentro do pipeline: uma tentativa que falhou no meio da cópia deixa bytes + // parciais nele, e reaproveitá-lo na tentativa seguinte concatenaria os dois pedaços. + var lido = await _tentativas.ExecuteAsync( + async cancelamento => + { + // O conteúdo é copiado para um buffer porque o SDK entrega o corpo por callback e + // fecha a conexão ao final da chamada. XML de NF-e tem dezenas de KB. + var destino = new MemoryStream(); + + await _cliente.GetObjectAsync( + new GetObjectArgs() + .WithBucket(bucket) + .WithObject(objectName) + .WithCallbackStream((fluxo, token) => fluxo.CopyToAsync(destino, token)), + cancelamento); + + return destino.ToArray(); + }, ct); - var conteudo = ConteudoGzip.Descompactar(destino.ToArray()); + var conteudo = ConteudoGzip.Descompactar(lido); return Result.Ok(new MemoryStream(conteudo, writable: false)); } @@ -142,16 +165,7 @@ public async Task> SubstituirAsync( try { - using var origem = new MemoryStream(bytes); - - await cliente.PutObjectAsync( - new PutObjectArgs() - .WithBucket(bucket) - .WithObject(objectName) - .WithStreamData(origem) - .WithObjectSize(bytes.Length) - .WithContentType("application/xml"), - ct); + await EnviarAsync(bucket, objectName, bytes, ct); return Result.Ok(new ObjetoArmazenadoDto( bucket, @@ -171,8 +185,10 @@ public async Task ExisteAsync(string bucket, string objectName, Cancellati { try { - await cliente.StatObjectAsync( - new StatObjectArgs().WithBucket(bucket).WithObject(objectName), ct); + await _tentativas.ExecuteAsync( + async cancelamento => await _cliente.StatObjectAsync( + new StatObjectArgs().WithBucket(bucket).WithObject(objectName), cancelamento), + ct); return true; } @@ -182,6 +198,34 @@ await cliente.StatObjectAsync( } } + /// + /// Envia o conteúdo para o objeto, repetindo enquanto a falha for transitória. + /// + /// + /// O é criado dentro do pipeline, uma vez por tentativa. Um stream + /// só é lido do início ao fim: reaproveitá-lo faria a segunda tentativa encontrá-lo no fim e gravar + /// um objeto de zero byte — sem erro nenhum, porque do ponto de vista do SDK o envio deu certo. + /// + /// Repetir é seguro porque a operação é idempotente: o mesmo nome com o mesmo conteúdo produz + /// exatamente o mesmo objeto, e o XML autorizado nunca muda. + /// + private async Task EnviarAsync(string bucket, string objectName, byte[] conteudo, CancellationToken ct) => + await _tentativas.ExecuteAsync( + async cancelamento => + { + using var origem = new MemoryStream(conteudo, writable: false); + + await _cliente.PutObjectAsync( + new PutObjectArgs() + .WithBucket(bucket) + .WithObject(objectName) + .WithStreamData(origem) + .WithObjectSize(conteudo.Length) + .WithContentType("application/xml"), + cancelamento); + }, + ct); + /// /// Particiona por ano e mês da competência, que a própria chave carrega. /// diff --git a/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj b/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj index 77e5440..0fc2d0a 100644 --- a/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj +++ b/src/eContabil.Infrastructure/eContabil.Infrastructure.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs b/tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs new file mode 100644 index 0000000..03a31eb --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Resiliencia/FalhaTransitoriaDeStorageTests.cs @@ -0,0 +1,80 @@ +using System.Net.Sockets; +using eContabil.Infrastructure.Resiliencia; +using Minio.Exceptions; +using Polly.Timeout; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Resiliencia; + +/// +/// O que separa uma nova tentativa útil de tempo jogado fora. +/// +/// +/// Quase toda exceção do SDK do MinIO herda de MinioException, inclusive as permanentes. Estes +/// testes existem para travar isso: são a diferença entre repetir um soluço de rede e repetir três vezes +/// uma credencial errada. +/// +public class FalhaTransitoriaDeStorageTests +{ + [Theory] + [InlineData(typeof(ConnectionException))] + [InlineData(typeof(InternalServerException))] + [InlineData(typeof(UnexpectedShortReadException))] + public void Ehtransitoria_ComFalhaDeRedeOuDoServidor_MandaTentarDeNovo(Type tipo) + { + var excecao = (Exception)Activator.CreateInstance(tipo)!; + + Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(excecao)); + } + + [Fact] + public void Ehtransitoria_ComFalhaAbaixoDoSdk_MandaTentarDeNovo() + { + // Conexão recusada e reset não chegam como exceção do MinIO: vêm da pilha de rede. + Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new HttpRequestException("connection refused"))); + Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new SocketException())); + Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new IOException("reset"))); + } + + [Fact] + public void Ehtransitoria_ComEstouroDoTempoLimiteDaTentativa_MandaTentarDeNovo() + { + Assert.True(FalhaTransitoriaDeStorage.Ehtransitoria(new TimeoutRejectedException())); + } + + [Theory] + [InlineData(typeof(ObjectNotFoundException))] + [InlineData(typeof(BucketNotFoundException))] + [InlineData(typeof(AccessDeniedException))] + [InlineData(typeof(ForbiddenException))] + [InlineData(typeof(InvalidObjectNameException))] + [InlineData(typeof(InvalidBucketNameException))] + [InlineData(typeof(EntityTooLargeException))] + public void Ehtransitoria_ComErroPermanenteDoMinio_NaoTentaDeNovo(Type tipo) + { + // Todas herdam de MinioException. Um `Handle` genérico repetiria arquivo + // inexistente e credencial errada três vezes para chegar exatamente ao mesmo lugar. + var excecao = (Exception)Activator.CreateInstance(tipo)!; + + Assert.IsAssignableFrom(excecao); + Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(excecao)); + } + + [Fact] + public void Ehtransitoria_ComCancelamento_NaoTentaDeNovo() + { + // Cancelar é decisão de quem chamou — o desligamento do worker, por exemplo. Insistir aqui + // seguraria o encerramento da aplicação por conta de um pipeline que se recusa a parar. + Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new OperationCanceledException())); + Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new TaskCanceledException())); + } + + [Fact] + public void Ehtransitoria_ComExcecaoDesconhecida_NaoTentaDeNovo() + { + // A regra é de permissão: o que não foi reconhecido como transitório é tratado como definitivo. + // Uma exceção nova do SDK entra por aqui, e o padrão seguro é não repetir. + Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new InvalidOperationException())); + Assert.False(FalhaTransitoriaDeStorage.Ehtransitoria(new MinioException("erro genérico"))); + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs index cce2375..d7131e5 100644 --- a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs +++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs @@ -1,8 +1,11 @@ using System.Globalization; using eContabil.Infrastructure.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Minio; using Minio.DataModel.Args; +using Polly.Registry; using Testcontainers.Minio; using Xunit; @@ -71,6 +74,26 @@ public async ValueTask InitializeAsync() await CriarBucketAsync(Opcoes.Value.BucketCertificados); } + /// + /// Monta o adaptador com o pipeline de resiliência real, como a aplicação o registra. + /// + /// + /// Pipeline de verdade, e não ResiliencePipeline.Empty: é o registro real que decide o que é + /// falha transitória, e testar com um pipeline vazio provaria que o adaptador funciona numa + /// configuração que nunca roda em lugar nenhum. + /// + public MinioXmlStorage CriarStorage() => + new(Cliente, Opcoes, _pipelines ??= MontarPipelines()); + + private ResiliencePipelineProvider? _pipelines; + + private static ResiliencePipelineProvider MontarPipelines() => + new ServiceCollection() + .AddLogging() + .AddArmazenamentoESeguranca(new ConfigurationBuilder().Build()) + .BuildServiceProvider() + .GetRequiredService>(); + private async Task CriarBucketAsync(string bucket) { if (!await Cliente.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket))) diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs new file mode 100644 index 0000000..c20c5e1 --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageResilienciaTests.cs @@ -0,0 +1,194 @@ +using System.Reflection; +using System.Text; +using eContabil.Infrastructure.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; +using Minio.DataModel.Response; +using Minio.Exceptions; +using NSubstitute; +using Polly.Registry; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Storage; + +/// +/// A nova tentativa no object storage economiza cota da SEFAZ. +/// +/// +/// Uma falha ao arquivar aborta o lote inteiro e o ponteiro de NSU não avança — a rodada seguinte busca +/// os mesmos documentos na SEFAZ de novo. A consulta é o recurso racionado; a gravação no MinIO não +/// custa nada. Um soluço de rede não pode custar uma ida à SEFAZ. +/// +/// O cliente é substituído aqui de propósito: provocar falha transitória num MinIO de verdade não é +/// reproduzível. O comportamento contra o serviço real está em . +/// +public class MinioXmlStorageResilienciaTests +{ + private const string ChaveValida = "35260711222333000181550010000123451123456787"; + + private readonly IMinioClient _cliente = Substitute.For(); + + [Fact] + public async Task SalvarAsync_ComFalhaTransitoriaQueSeResolve_ArquivaSemPropagarErro() + { + var tentativas = 0; + + _cliente.PutObjectAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ++tentativas < 3 + ? throw new ConnectionException("conexão recusada") + : Task.FromResult(null!)); + + var resultado = await Montar().SalvarAsync( + ChaveValida, Xml(), TestContext.Current.CancellationToken); + + Assert.True(resultado.Sucesso); + Assert.Equal(3, tentativas); + } + + [Fact] + public async Task SalvarAsync_AoTentarDeNovo_EnviaOConteudoInteiroEmCadaTentativa() + { + // O stream nasce dentro do pipeline, uma vez por tentativa. Reaproveitá-lo faria a segunda + // tentativa encontrá-lo no fim e gravar um objeto de zero byte — sem erro nenhum, porque do + // ponto de vista do SDK o envio deu certo. O XML sumiria em silêncio. + var lidos = new List(); + + _cliente.PutObjectAsync(Arg.Any(), Arg.Any()) + .Returns(chamada => + { + lidos.Add(LerConteudo(chamada.Arg()!).Length); + + return lidos.Count < 2 + ? throw new ConnectionException("conexão caiu no meio do envio") + : Task.FromResult(null!); + }); + + var resultado = await Montar().SalvarAsync( + ChaveValida, Xml(), TestContext.Current.CancellationToken); + + Assert.True(resultado.Sucesso); + Assert.Equal(2, lidos.Count); + Assert.All(lidos, tamanho => Assert.Equal(ConteudoXml.Length, tamanho)); + } + + [Fact] + public async Task SalvarAsync_ComErroPermanente_FalhaNaPrimeiraTentativa() + { + // Credencial errada não melhora com insistência: repetir só atrasaria o lote para chegar ao + // mesmo lugar, segurando o worker enquanto as outras empresas esperam. + var tentativas = 0; + + _cliente.PutObjectAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => + { + tentativas++; + throw new AccessDeniedException("credencial inválida"); + }); + + var resultado = await Montar().SalvarAsync( + ChaveValida, Xml(), TestContext.Current.CancellationToken); + + Assert.True(resultado.Falha); + Assert.Equal("storage.indisponivel", resultado.Erro!.Codigo); + Assert.Equal(1, tentativas); + } + + [Fact] + public async Task SalvarAsync_ComFalhaQueNaoCede_DesisteEDevolveResultadoEmVezDeLancar() + { + // Quem chamou precisa saber que o NSU não pode avançar. Exceção subindo faria o lote inteiro + // parecer erro interno da aplicação. + var tentativas = 0; + + _cliente.PutObjectAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => + { + tentativas++; + throw new ConnectionException("MinIO fora"); + }); + + var resultado = await Montar().SalvarAsync( + ChaveValida, Xml(), TestContext.Current.CancellationToken); + + Assert.True(resultado.Falha); + + // Uma tentativa original mais três novas. + Assert.Equal(4, tentativas); + } + + [Fact] + public async Task ObterAsync_ComObjetoInexistente_NaoTentaDeNovo() + { + var tentativas = 0; + + _cliente.GetObjectAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => + { + tentativas++; + throw new ObjectNotFoundException(); + }); + + var resultado = await Montar().ObterAsync( + "xmls", "2026/07/inexistente.xml", TestContext.Current.CancellationToken); + + Assert.True(resultado.Falha); + Assert.Equal("storage.objeto_inexistente", resultado.Erro!.Codigo); + Assert.Equal(1, tentativas); + } + + private MinioXmlStorage Montar() + { + // Objeto ainda não arquivado: sem isso, `SalvarAsync` encerraria em "já existia" sem gravar. + _cliente.StatObjectAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => throw new ObjectNotFoundException()); + + var opcoes = Options.Create(new MinioOptions + { + Endpoint = "localhost:9000", + AccessKey = "chave", + SecretKey = "segredo", + UsarSsl = false, + BucketXmls = "xmls", + BucketCertificados = "certificados" + }); + + return new MinioXmlStorage(_cliente, opcoes, Pipelines()); + } + + /// + /// O registro real da aplicação, não um pipeline montado à mão: é ele que decide o que é falha + /// transitória, e é ele que precisa estar sob teste. + /// + private static ResiliencePipelineProvider Pipelines() => + new ServiceCollection() + .AddLogging() + .AddArmazenamentoESeguranca(new ConfigurationBuilder().Build()) + .BuildServiceProvider() + .GetRequiredService>(); + + private const string ConteudoXml = + """"""; + + private static MemoryStream Xml() => new(Encoding.UTF8.GetBytes(ConteudoXml)); + + /// Lê o que a tentativa entregou ao SDK. + /// + /// Por reflexão porque o SDK declara ObjectStreamData como interno. É o preço de provar, sem + /// depender de um MinIO de verdade, que cada tentativa recebe o conteúdo inteiro — o defeito que + /// este teste tranca grava objeto de zero byte sem erro nenhum. + /// + private static string LerConteudo(PutObjectArgs argumentos) + { + var propriedade = typeof(PutObjectArgs).GetProperty( + "ObjectStreamData", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + var fluxo = (Stream)propriedade!.GetValue(argumentos)!; + using var leitor = new StreamReader(fluxo, Encoding.UTF8, leaveOpen: true); + + return leitor.ReadToEnd(); + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs index af08ac5..506cd4e 100644 --- a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs @@ -17,7 +17,7 @@ public async Task SalvarAsync_ComXmlNovo_ArquivaParticionadoPorAnoEMes() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var chave = ChaveDe(1); var resultado = await storage.SalvarAsync(chave, Xml(), TestContext.Current.CancellationToken); @@ -43,7 +43,7 @@ public async Task SalvarEventoAsync_NaoOcupaOLugarDaNota() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var chave = ChaveDe(90); var evento = await storage.SalvarEventoAsync( @@ -67,7 +67,7 @@ public async Task SalvarEventoAsync_ComNsusDiferentes_NaoSobrescreveOEventoAnter return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var chave = ChaveDe(91); var primeiro = await storage.SalvarEventoAsync( @@ -89,7 +89,7 @@ public async Task SalvarAsync_ComOMesmoXmlDuasVezes_NaoRegrava() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); await storage.SalvarAsync(ChaveDe(2), Xml(), TestContext.Current.CancellationToken); // O retry de um job de sincronização passa por aqui o tempo todo, e a mesma NF-e ainda interessa @@ -109,7 +109,7 @@ public async Task ObterAsync_ComObjetoArquivado_DevolveOsMesmosBytes() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var gravado = await storage.SalvarAsync(ChaveDe(3), Xml(), TestContext.Current.CancellationToken); var lido = await storage.ObterAsync( @@ -129,7 +129,7 @@ public async Task ObterAsync_ComObjetoInexistente_RetornaNaoEncontradoSemLancar( return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var resultado = await storage.ObterAsync( "xmls", "2026/07/inexistente.xml", TestContext.Current.CancellationToken); @@ -147,7 +147,7 @@ public async Task SalvarAsync_ComChaveInvalida_NemChegaAoStorage() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var resultado = await storage.SalvarAsync("123", Xml(), TestContext.Current.CancellationToken); @@ -163,7 +163,7 @@ public async Task SalvarAsync_ComConteudoVazio_Falha() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var resultado = await storage.SalvarAsync( ChaveDe(4), new MemoryStream(), TestContext.Current.CancellationToken); @@ -186,7 +186,7 @@ public async Task ObterAsync_ComObjetoArquivadoEmGzip_DevolveOXmlLegivel() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var chave = ChaveDe(200); var arquivado = await storage.SalvarAsync( @@ -215,7 +215,7 @@ public async Task SubstituirAsync_TrocaOConteudoEDevolveONovoHash() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var chave = ChaveDe(201); var original = await storage.SalvarAsync( @@ -240,7 +240,7 @@ public async Task SubstituirAsync_ComConteudoVazio_RecusaEmVezDeApagarOArquivo() return; } - var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var storage = minio.CriarStorage(); var resultado = await storage.SubstituirAsync( "xmls", "2026/07/qualquer.xml", new MemoryStream(), TestContext.Current.CancellationToken);