From 82b9ef2c0989f58efb641e5aaf27542e26963116 Mon Sep 17 00:00:00 2001 From: Maria Fernanda Date: Wed, 11 Nov 2026 09:30:41 -0300 Subject: [PATCH 1/2] =?UTF-8?q?T-313=20-=20Garantir=20a=20reten=C3=A7?= =?UTF-8?q?=C3=A3o=20do=20registro=20de=20auditoria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registra na DEC-096 a retenção pela fila confirmada pela P.O. e atualiza a arquitetura. Instrumenta o consumo de no-concluido com os resultados gravado, duplicado, descartado e falha, mantendo os erros de persistência sanitizados e a reentrega. Testa a continuidade da simulação durante a falha da trilha, a recuperação sem duplicação, os logs e a exposição das métricas. Validação: make check aprovado com 1575 testes, zero falhas, zero erros e 6 pulados. Review: auto --- .../synapse/api/core/metrics/AppMetrics.java | 9 ++ .../java/synapse/api/job/ConsumidoresJob.java | 28 ++++- .../java/synapse/api/job/ExcecoesJob.java | 9 ++ .../synapse/api/job/JobEventosService.java | 4 +- .../api/job/BuscaMetaAnunciadaTests.java | 8 +- .../api/job/NoConcluidoConsumidorTests.java | 96 +++++++++++++++- .../job/NoConcluidoObservabilidadeTests.java | 106 ++++++++++++++++++ docs/ARCHITECTURE.md | 2 +- docs/decisoes/dec-096.md | 27 +++++ 9 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java create mode 100644 docs/decisoes/dec-096.md diff --git a/api/src/main/java/synapse/api/core/metrics/AppMetrics.java b/api/src/main/java/synapse/api/core/metrics/AppMetrics.java index 65a71d70..37812593 100644 --- a/api/src/main/java/synapse/api/core/metrics/AppMetrics.java +++ b/api/src/main/java/synapse/api/core/metrics/AppMetrics.java @@ -187,6 +187,15 @@ public Timer regraExtraidaDuracao(String resultado) { .register(this.registry); } + /** Resultados fechados: gravado, duplicado, descartado e falha. */ + public Counter noConcluidoConsumido(String resultado) { + return Counter.builder("no.concluido.consumo") + .description( + "Tentativas de consumo de no-concluido; reentrega já gravada conta como duplicado, não gravado") + .tag("resultado", resultado) + .register(this.registry); + } + /** * Entregas de {@code simulacao-concluida} consumidas, por desfecho e resultado do * consumo. Conta entregas, não jobs: a reentrega de um desfecho já aplicado sai como diff --git a/api/src/main/java/synapse/api/job/ConsumidoresJob.java b/api/src/main/java/synapse/api/job/ConsumidoresJob.java index 223c0009..943f174d 100644 --- a/api/src/main/java/synapse/api/job/ConsumidoresJob.java +++ b/api/src/main/java/synapse/api/job/ConsumidoresJob.java @@ -255,16 +255,21 @@ class NoConcluidoConsumidor { private final CorrelationContext correlacao; - NoConcluidoConsumidor(JobEventosService servico, EmissoresSse emissores, CorrelationContext correlacao) { + private final AppMetrics metricas; + + NoConcluidoConsumidor(JobEventosService servico, EmissoresSse emissores, CorrelationContext correlacao, + AppMetrics metricas) { this.servico = servico; this.emissores = emissores; this.correlacao = correlacao; + this.metricas = metricas; } @RabbitListener(queues = RabbitTopologyConfig.NO_CONCLUIDO) void receber(NoConcluidoDto evento) { UUID jobId = evento.job_id(); if (jobId == null) { + this.metricas.noConcluidoConsumido("descartado").increment(); log.atWarn().log("no-concluido sem job_id; evento descartado"); return; } @@ -274,11 +279,13 @@ void receber(NoConcluidoDto evento) { EtapaDoGrafo no = EtapaDoGrafo.deEvento(evento.no()); if (eventoId == null || no == null || evento.concluido_em() == null || evento.conclusao() == null || !temResumo(evento.conclusao())) { + this.metricas.noConcluidoConsumido("descartado").increment(); log.atWarn().addKeyValue("no", evento.no()).log("no-concluido inválido; evento descartado"); return; } if (no == EtapaDoGrafo.CONFIRMACAO) { + this.metricas.noConcluidoConsumido("descartado").increment(); // JobService já grava essa linha na mesma transação da // confirmação; gravar de novo aqui duplicaria a trilha. log.atInfo().log("no-concluido do nó confirmacao descartado; trilha já gravada pela confirmação"); @@ -290,10 +297,25 @@ void receber(NoConcluidoDto evento) { busca = this.servico.registrarNoConcluido(jobId, eventoId, no, evento); } catch (EmptyResultDataAccessException ex) { - log.atWarn().setCause(ex).log("no-concluido de job desconhecido; evento descartado"); + this.metricas.noConcluidoConsumido("descartado").increment(); + log.atWarn().log("no-concluido de job desconhecido; evento descartado"); return; } - log.atDebug().addKeyValue("no", no.paraEvento()).log("no-concluido gravado na trilha"); + catch (org.springframework.dao.DataAccessException + | org.springframework.transaction.TransactionException ex) { + this.metricas.noConcluidoConsumido("falha").increment(); + log.atWarn() + .addKeyValue("no", no.paraEvento()) + .addKeyValue("resultado", "falha") + .log("falha na gravação da trilha; mensagem será reentregue"); + throw new FalhaPersistenciaTrilhaException(); + } + String resultado = busca == JobEventosService.BuscaMetaAnunciada.DUPLICADA ? "duplicado" : "gravado"; + this.metricas.noConcluidoConsumido(resultado).increment(); + log.atDebug() + .addKeyValue("no", no.paraEvento()) + .addKeyValue("resultado", resultado) + .log("no-concluido processado na trilha"); String status = busca.status(); if (status != null) { // Depois do commit: quem buscar o job agora encontra o campo, ou não o diff --git a/api/src/main/java/synapse/api/job/ExcecoesJob.java b/api/src/main/java/synapse/api/job/ExcecoesJob.java index 2c450513..535e1926 100644 --- a/api/src/main/java/synapse/api/job/ExcecoesJob.java +++ b/api/src/main/java/synapse/api/job/ExcecoesJob.java @@ -8,6 +8,15 @@ import org.springframework.http.HttpStatus; +class FalhaPersistenciaTrilhaException extends RuntimeException { + + FalhaPersistenciaTrilhaException() { + // O container registra a cadeia inteira; não retenha a exceção com artefatos. + super("falha de persistência ao consumir no-concluido", null, false, true); + } + +} + class FalhaPersistenciaEtapaException extends RuntimeException { FalhaPersistenciaEtapaException() { diff --git a/api/src/main/java/synapse/api/job/JobEventosService.java b/api/src/main/java/synapse/api/job/JobEventosService.java index 92c13345..6c7636d8 100644 --- a/api/src/main/java/synapse/api/job/JobEventosService.java +++ b/api/src/main/java/synapse/api/job/JobEventosService.java @@ -314,7 +314,7 @@ BuscaMetaAnunciada registrarNoConcluido(UUID jobId, UUID eventoId, EtapaDoGrafo } if (!this.repository.inserirTrilhaNo(eventoId, jobId, simulacaoId, no, evento)) { - return BuscaMetaAnunciada.NENHUMA; + return BuscaMetaAnunciada.DUPLICADA; } if (abreBusca && this.repository.buscaMetaEmCurso(jobId)) { return BuscaMetaAnunciada.INICIADA; @@ -332,7 +332,7 @@ BuscaMetaAnunciada registrarNoConcluido(UUID jobId, UUID eventoId, EtapaDoGrafo */ enum BuscaMetaAnunciada { - NENHUMA(null), INICIADA("buscando_meta"), ENCERRADA("busca_meta_encerrada"); + NENHUMA(null), DUPLICADA(null), INICIADA("buscando_meta"), ENCERRADA("busca_meta_encerrada"); private final @Nullable String status; diff --git a/api/src/test/java/synapse/api/job/BuscaMetaAnunciadaTests.java b/api/src/test/java/synapse/api/job/BuscaMetaAnunciadaTests.java index 1605336e..fb120e93 100644 --- a/api/src/test/java/synapse/api/job/BuscaMetaAnunciadaTests.java +++ b/api/src/test/java/synapse/api/job/BuscaMetaAnunciadaTests.java @@ -52,7 +52,9 @@ private static NoConcluidoDto decisao(UUID jobId) throws Exception { void oComecoEOFimDaBuscaSaoAnunciadosDepoisDeGravados(BuscaMetaAnunciada busca) throws Exception { var servico = mock(JobEventosService.class); var emissores = mock(EmissoresSse.class); - var consumidor = new NoConcluidoConsumidor(servico, emissores, new CorrelationContext()); + var consumidor = new NoConcluidoConsumidor(servico, emissores, new CorrelationContext(), + new synapse.api.core.metrics.AppMetrics( + new io.micrometer.core.instrument.simple.SimpleMeterRegistry())); UUID jobId = UUID.randomUUID(); NoConcluidoDto evento = decisao(jobId); when(servico.registrarNoConcluido(eq(jobId), any(), eq(EtapaDoGrafo.DECISAO), eq(evento))).thenReturn(busca); @@ -105,7 +107,9 @@ void oJobComABuscaEmCursoSaiNoContratoEOSemBuscaNaoTrazOCampo() throws Exception void semBuscaAbertaNadaVaiAoSse() throws Exception { var servico = mock(JobEventosService.class); var emissores = mock(EmissoresSse.class); - var consumidor = new NoConcluidoConsumidor(servico, emissores, new CorrelationContext()); + var consumidor = new NoConcluidoConsumidor(servico, emissores, new CorrelationContext(), + new synapse.api.core.metrics.AppMetrics( + new io.micrometer.core.instrument.simple.SimpleMeterRegistry())); UUID jobId = UUID.randomUUID(); NoConcluidoDto evento = decisao(jobId); when(servico.registrarNoConcluido(eq(jobId), any(), eq(EtapaDoGrafo.DECISAO), eq(evento))) diff --git a/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java b/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java index 20d6b6c5..7673cac8 100644 --- a/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java +++ b/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java @@ -73,7 +73,7 @@ static boolean dockerIsAvailable() { static void subirAplicacao() throws Exception { postgres = new PostgreSQLContainer("postgres:18-alpine"); postgres.start(); - rabbitmq = new RabbitMQContainer("rabbitmq:3.13-management-alpine"); + rabbitmq = new RabbitMQContainer("rabbitmq:3.13-management-alpine").withStartupTimeout(Duration.ofMinutes(2)); rabbitmq.start(); contexto = new SpringApplicationBuilder(ApiApplication.class).web(WebApplicationType.SERVLET) @@ -121,6 +121,96 @@ void confirmarQueAFilaEsvaziou() { // --- Cenários ----------------------------------------------------------------- + @Test + void falhaDaTrilhaNaoInterrompeSimulacaoEReentregaGravaUmaUnicaVez() throws Exception { + UUID jobId = criarJob(); + UUID regraId = criarRegra(jobId); + UUID codigoId = criarCodigoGerado(jobId, regraId); + UUID resultadoId = criarResultado(jobId, codigoId); + UUID eventoId = UUID.randomUUID(); + double falhas = consumos("falha"); + double gravados = consumos("gravado"); + double duplicados = consumos("duplicado"); + try (CapturaDeLog captura = new CapturaDeLog(NoConcluidoConsumidor.class)) { + try (Connection connection = comoDono(); Statement statement = connection.createStatement()) { + statement.execute("REVOKE INSERT ON trilhas_auditoria FROM synapse_api"); + } + try { + publicar(eventoId, jobId, "geracao_codigo", regraId, codigoId, null, null); + await().atMost(Duration.ofSeconds(15)) + .untilAsserted(() -> assertThat(consumos("falha")).isGreaterThan(falhas)); + String corpo = """ + {"job_id":"%s","resultado_id":"%s","status":"sucesso","veredito":"viavel"} + """.formatted(jobId, resultadoId); + ContratoDeEvento.validar("simulacao-concluida", corpo); + rabbitTemplate.send("", RabbitTopologyConfig.SIMULACAO_CONCLUIDA_API, + MessageBuilder.withBody(corpo.getBytes(StandardCharsets.UTF_8)) + .setContentType(MessageProperties.CONTENT_TYPE_JSON) + .setDeliveryMode(MessageDeliveryMode.PERSISTENT) + .build()); + await().atMost(Duration.ofSeconds(15)) + .untilAsserted(() -> assertThat(contexto.getBean(JobService.class).buscar(jobId).status()) + .isEqualTo("aguardando_decisao_usuario")); + assertThat(contarTrilhas(jobId)).isZero(); + assertThat(consumos("gravado")).isEqualTo(gravados); + } + finally { + try (Connection connection = comoDono(); Statement statement = connection.createStatement()) { + statement.execute("GRANT INSERT ON trilhas_auditoria TO synapse_api"); + } + } + await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { + assertThat(contarTrilhas(jobId)).isEqualTo(1); + assertThat(consumos("gravado")).isEqualTo(gravados + 1); + assertThat(resultadoDaSimulacao(codigoId)).isEqualTo(resultadoId); + }); + publicar(eventoId, jobId, "geracao_codigo", regraId, codigoId, null, null); + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(consumos("duplicado")).isEqualTo(duplicados + 1)); + assertThat(contarTrilhas(jobId)).isEqualTo(1); + assertThat(consumos("gravado")).isEqualTo(gravados + 1); + var falha = captura.eventos() + .stream() + .filter(e -> e.getMessage().equals("falha na gravação da trilha; mensagem será reentregue")) + .findFirst() + .orElseThrow(); + String linha = CapturaDeLog.emJson(falha); + ContratoDeEvento.validarLog(linha); + var json = new JsonMapper().readTree(linha); + assertThat(json.path("job_id").asString()).isEqualTo(jobId.toString()); + assertThat(json.path("extra").path("no").asString()).isEqualTo("geracao_codigo"); + assertThat(linha).doesNotContain("conclusao", "exception", "INSERT", "senha-de-teste"); + } + int porta = Objects + .requireNonNull( + ((org.springframework.boot.web.server.context.WebServerApplicationContext) contexto).getWebServer()) + .getPort(); + var resposta = java.net.http.HttpClient.newHttpClient() + .send(java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://localhost:" + porta + "/metrics")) + .GET() + .build(), java.net.http.HttpResponse.BodyHandlers.ofString()); + assertThat(resposta.statusCode()).isEqualTo(200); + for (String resultado : List.of("gravado", "duplicado", "falha")) { + var amostras = resposta.body() + .lines() + .filter(l -> l.startsWith("no_concluido_consumo_total{") + && l.contains("resultado=\"" + resultado + "\"")) + .toList(); + assertThat(amostras).hasSize(1); + assertThat(Double.parseDouble(amostras.getFirst().substring(amostras.getFirst().lastIndexOf(' ') + 1))) + .isEqualTo(consumos(resultado)) + .isPositive(); + } + } + + private static double consumos(String resultado) { + var contador = contexto.getBean(io.micrometer.core.instrument.MeterRegistry.class) + .find("no.concluido.consumo") + .tag("resultado", resultado) + .counter(); + return contador == null ? 0 : contador.count(); + } + @Test void noDeGeracaoDeCodigoGravaATrilhaEASimulacao() throws Exception { UUID jobId = criarJob(); @@ -296,11 +386,11 @@ void oServicoAnunciaOComecoEOFimUmaVezSoMesmoComReentrega() throws Exception { assertThat(registrarDecisao(servico, abertura, jobId, regraId, "busca_meta")) .isEqualTo(JobEventosService.BuscaMetaAnunciada.INICIADA); assertThat(registrarDecisao(servico, abertura, jobId, regraId, "busca_meta")) - .isEqualTo(JobEventosService.BuscaMetaAnunciada.NENHUMA); + .isEqualTo(JobEventosService.BuscaMetaAnunciada.DUPLICADA); assertThat(registrarDecisao(servico, fecho, jobId, regraId, null)) .isEqualTo(JobEventosService.BuscaMetaAnunciada.ENCERRADA); assertThat(registrarDecisao(servico, fecho, jobId, regraId, null)) - .isEqualTo(JobEventosService.BuscaMetaAnunciada.NENHUMA); + .isEqualTo(JobEventosService.BuscaMetaAnunciada.DUPLICADA); } @Test diff --git a/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java b/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java new file mode 100644 index 00000000..6c4948c3 --- /dev/null +++ b/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java @@ -0,0 +1,106 @@ +package synapse.api.job; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.transaction.TransactionSystemException; + +import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.metrics.AppMetrics; +import synapse.api.core.sse.EmissoresSse; +import synapse.api.job.JobEventosService.BuscaMetaAnunciada; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class NoConcluidoObservabilidadeTests { + + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + @AfterEach + void fecharRegistry() { + this.registry.close(); + } + + private final JobEventosService servico = mock(JobEventosService.class); + + private final EmissoresSse emissores = mock(EmissoresSse.class); + + private static NoConcluidoDto evento(String no) { + return new NoConcluidoDto(UUID.randomUUID(), UUID.randomUUID(), no, Instant.now(), + new JsonMapper().readTree("{\"resumo\":\"conteudo reservado\"}"), null, null, null, null, null); + } + + @ParameterizedTest + @EnumSource(BuscaMetaAnunciada.class) + void contabilizaApenasOResultadoConfirmado(BuscaMetaAnunciada resultado) { + when(this.servico.registrarNoConcluido(any(), any(), any(), any())).thenReturn(resultado); + new NoConcluidoConsumidor(this.servico, this.emissores, new CorrelationContext(), new AppMetrics(this.registry)) + .receber(evento("decisao")); + String esperado = resultado == BuscaMetaAnunciada.DUPLICADA ? "duplicado" : "gravado"; + assertThat(this.registry.get("no.concluido.consumo").tag("resultado", esperado).counter().count()).isEqualTo(1); + assertThat(this.registry.getMeters()).hasSize(1); + } + + @ParameterizedTest + @ValueSource(strings = { "confirmacao", "inventado", "desconhecido" }) + void descarteNaoLancaNemAnuncia(String no) { + if (no.equals("desconhecido")) { + when(this.servico.registrarNoConcluido(any(), any(), any(), any())) + .thenThrow(new EmptyResultDataAccessException(1)); + } + new NoConcluidoConsumidor(this.servico, this.emissores, new CorrelationContext(), new AppMetrics(this.registry)) + .receber(evento(no.equals("desconhecido") ? "decisao" : no)); + assertThat(this.registry.get("no.concluido.consumo").tag("resultado", "descartado").counter().count()) + .isEqualTo(1); + verifyNoInteractions(this.emissores); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void falhaDeBancoOuCommitPropagaSemConteudoERestauraCorrelacao(boolean commit) throws Exception { + RuntimeException falha = commit ? new TransactionSystemException("conteudo reservado senha") + : new DataAccessResourceFailureException("conteudo reservado senha"); + when(this.servico.registrarNoConcluido(any(), any(), any(), any())).thenThrow(falha); + var evento = evento("decisao"); + MDC.put("job_id", "anterior"); + try (var captura = new CapturaDeLog(NoConcluidoConsumidor.class)) { + var consumidor = new NoConcluidoConsumidor(this.servico, this.emissores, new CorrelationContext(), + new AppMetrics(this.registry)); + assertThatThrownBy(() -> consumidor.receber(evento)).isInstanceOf(FalhaPersistenciaTrilhaException.class) + .hasNoCause() + .hasMessage("falha de persistência ao consumir no-concluido"); + assertThat(MDC.get("job_id")).isEqualTo("anterior"); + assertThat(this.registry.get("no.concluido.consumo").tag("resultado", "falha").counter().count()) + .isEqualTo(1); + assertThat(this.registry.getMeters()).hasSize(1); + assertThat(captura.eventos()).hasSize(1); + String linha = CapturaDeLog.emJson(captura.eventos().getFirst()); + ContratoDeEvento.validarLog(linha); + assertThat(linha).doesNotContain("conteudo reservado", "senha", "exception", "conclusao"); + var json = new JsonMapper().readTree(linha); + assertThat(json.path("job_id").asString()).isEqualTo(Objects.requireNonNull(evento.job_id()).toString()); + assertThat(json.path("extra").path("no").asString()).isEqualTo("decisao"); + verifyNoInteractions(this.emissores); + } + finally { + MDC.clear(); + } + } + +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a1b2bb85..86f560e9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -340,7 +340,7 @@ O escopo e as decisões de integração estão consolidados em [Fluxo e decisõe - **Consistência entre criação do job e publicação do evento** - se a API cai entre salvar no Postgres e publicar no RabbitMQ, o job fica "perdido". Resolvido com **outbox transacional**: a criação do `job` e a gravação do evento numa tabela `outbox_event` acontecem na mesma transação Postgres; uma tarefa agendada (`@Scheduled`, já que roda em instância única) lê os eventos pendentes, publica no RabbitMQ e marca como enviado. Dispensa infraestrutura de CDC (ex. Debezium), ao custo de uma pequena latência (o intervalo do poller) entre a criação do job e a publicação do evento. - **Dono do estado e das decisões** - a API é o único serviço que escreve nas tabelas que representam estado e decisão de negócio: `job`, transições da máquina de estados, trilha de auditoria, histórico. Worker e codegen gravam apenas os artefatos que eles mesmos produzem (resultado, código gerado, prompts), em tabelas próprias e com permissão restrita a elas (seção 6.2), e nunca chamam a API via HTTP para isso - publicam eventos com a referência da linha gravada. Assim a API segue como fonte única de verdade do que o usuário e o auditor veem, sem reintroduzir acoplamento síncrono entre serviços. - **Integração RabbitMQ** - producer de `regra-submetida`, `parametros-confirmados` e `job-encerrado` (para o codegen); consumer de `simulacao-concluida` (do Worker, pela exchange fanout), `no-concluido` e `etapa-alterada` (do codegen, por fila simples). Ver catálogo completo na seção 6.3. -- **Persistência da trilha:** os eventos `no-concluido` recebem confirmação depois da gravação e permitem redelivery em falha. Transições e ações produzidas pela própria API são gravadas transacionalmente. A P.O. confirmou a retenção pela fila no cenário 2 da US04. A simulação prossegue durante a falha de gravação da trilha (DEC-087; verificação na T-313). +- **Persistência da trilha:** a fila durável e as mensagens persistentes de `no-concluido` atendem à retenção do cenário 2 da US04, conforme confirmação da P.O. e [DEC-096](decisoes/dec-096.md). A API confirma depois do commit e reentrega em falha; o índice único de `evento_id` impede duplicação. O consumidor separado de `simulacao-concluida` permite que o job avance durante uma falha isolada de escrita da trilha. Uma indisponibilidade total do Postgres único também afeta a simulação e está fora dessa garantia. O contador `no_concluido_consumo_total` distingue tentativas gravadas, duplicadas, descartadas e falhas. Transições e ações produzidas pela própria API continuam transacionais. - **Gestão de SSE** - mapeamento `job_id → emissores conectados`, para repassar cada evento de progresso consumido do RabbitMQ ao(s) cliente(s) Frontend inscritos naquele job. Também é o que sustenta a retenção do trabalho na saída abrupta (US07, cenário 2): o job vive na API, não na aba do navegador, então fechar a página não descarta o processamento - o Frontend apenas alerta antes de sair e reencontra o job no histórico. - **Persistência (JDBC/JdbcTemplate)** - repositórios da tabela `job` e das tabelas relacionadas (ver seção 5), reaproveitados pelas fatias que precisam. Inclui os artefatos que a própria API produz: áudio (`bytea`) e transcrição, em tabela separada das de consulta frequente para não pesar o dia a dia. - **Dono das migrations** - o schema é único e a API é quem o versiona, via Liquibase (seção 5), inclusive as tabelas que Worker e codegen escrevem. Eles inserem; não criam nem alteram estrutura. diff --git a/docs/decisoes/dec-096.md b/docs/decisoes/dec-096.md new file mode 100644 index 00000000..6c095db9 --- /dev/null +++ b/docs/decisoes/dec-096.md @@ -0,0 +1,27 @@ +# DEC-096: retenção da trilha de auditoria pela fila com reentrega + +## Contexto + +O cenário 2 da US04 exige que uma falha na gravação do registro de auditoria não interrompa a simulação e que o registro permaneça retido até a recuperação da gravação. A P.O. confirmou que deve ser mantido o fluxo atual de retenção pela fila, conforme o item 6 das decisões confirmadas na DEC-087. + +O codegen grava os artefatos no Postgres e publica `no-concluido` com referências e a conclusão da etapa. A mensagem é persistente e a fila `no-concluido` é durável. A API consome a mensagem e grava `trilhas_auditoria` em uma transação própria. + +## Decisão + +1. A retenção exigida pelo cenário 2 é atendida pela fila durável com reentrega, conforme confirmado pela P.O. Não será criado um buffer em memória ou arquivo. +2. A API confirma a mensagem apenas depois do retorno do serviço transacional, após o commit. Uma falha de gravação ou de commit propaga como falha de persistência sanitizada. Com `default-requeue-rejected: true`, a mensagem volta à fila para nova tentativa. +3. O índice único de `trilhas_auditoria.evento_id` e o `INSERT ... ON CONFLICT DO NOTHING` tornam a gravação idempotente. Se o commit ocorrer e a confirmação da mensagem se perder, a reentrega encontra a linha existente e não a duplica. +4. A trilha e o resultado têm consumidores próprios. `simulacao-concluida` é consumido pela fila `simulacao-concluida.api` e pode levar o job à decisão do usuário enquanto a gravação da trilha falha. A trilha pode chegar depois do resultado e completar suas referências. +5. O contador `no.concluido.consumo`, exposto como `no_concluido_consumo_total`, mede tentativas com a tag `resultado` em `gravado`, `duplicado`, `descartado` ou `falha`. Uma reentrega já gravada é `duplicado`, não outra gravação. Descartes de validação continuam sem exceção. JSON rejeitado pelo conversor antes de entrar no consumidor não faz parte deste contador. +6. A falha é registrada com `job_id`, o nó em `extra.no` e o resultado, sem conclusão nem exceção original que possa conter artefatos. Não há identificadores de negócio nas tags da métrica. + +## Consequências + +- A trilha fica temporariamente incompleta durante a falha e se recupera por reentrega quando a escrita volta a funcionar. Falhas persistentes podem produzir repetidas tentativas e exigem acompanhamento operacional. +- O sistema tem um único Postgres. Sua indisponibilidade total também interrompe o processamento de negócio. Esta garantia trata de falha isolada na escrita da trilha, com os demais recursos funcionando. +- A garantia pressupõe que a mensagem tenha sido publicada no RabbitMQ e que o broker preserve a fila. Não cobre falhas anteriores à publicação, perda do armazenamento do broker ou indisponibilidade total do RabbitMQ. Não acrescenta outbox ao codegen nem fila de mensagens mortas. +- O teste de integração revoga apenas o `INSERT` da API em `trilhas_auditoria`, publica os dois eventos do mesmo job e verifica a evolução do estado durante a falha. Ao restaurar o privilégio, verifica uma única linha, a ligação ao resultado, a deduplicação, os logs serializados e o contador no registry e em `/metrics`. + +## Histórico + +- 2026-11-09: T-313, com a confirmação da P.O. fornecida pelo usuário e já registrada na DEC-087. From 83480f2fa9384864c701dedf1debd1f0e760606e Mon Sep 17 00:00:00 2001 From: Pedro Garcia Date: Thu, 12 Nov 2026 08:18:57 -0300 Subject: [PATCH 2/2] =?UTF-8?q?T-313=20-=20Registrar=20a=20classe=20da=20f?= =?UTF-8?q?alha=20na=20grava=C3=A7=C3=A3o=20da=20trilha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O log "falha na gravação da trilha" passa a trazer extra.classe_falha com o nome simples da classe da exceção, para distinguir uma referência inexistente de um banco indisponível sem expor a mensagem nem a causa. O NoConcluidoDto da T-301 ganhou o campo resultado_id, e o teste de observabilidade passa o argumento a mais. Co-Authored-By: Claude Sonnet 5.5 --- api/src/main/java/synapse/api/job/ConsumidoresJob.java | 1 + .../test/java/synapse/api/job/NoConcluidoConsumidorTests.java | 1 + .../java/synapse/api/job/NoConcluidoObservabilidadeTests.java | 3 ++- docs/decisoes/dec-096.md | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/main/java/synapse/api/job/ConsumidoresJob.java b/api/src/main/java/synapse/api/job/ConsumidoresJob.java index 943f174d..fa78c7ff 100644 --- a/api/src/main/java/synapse/api/job/ConsumidoresJob.java +++ b/api/src/main/java/synapse/api/job/ConsumidoresJob.java @@ -307,6 +307,7 @@ void receber(NoConcluidoDto evento) { log.atWarn() .addKeyValue("no", no.paraEvento()) .addKeyValue("resultado", "falha") + .addKeyValue("classe_falha", ex.getClass().getSimpleName()) .log("falha na gravação da trilha; mensagem será reentregue"); throw new FalhaPersistenciaTrilhaException(); } diff --git a/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java b/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java index e6db500e..f23c0cb2 100644 --- a/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java +++ b/api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java @@ -183,6 +183,7 @@ void falhaDaTrilhaNaoInterrompeSimulacaoEReentregaGravaUmaUnicaVez() throws Exce var json = new JsonMapper().readTree(linha); assertThat(json.path("job_id").asString()).isEqualTo(jobId.toString()); assertThat(json.path("extra").path("no").asString()).isEqualTo("geracao_codigo"); + assertThat(json.path("extra").path("classe_falha").asString()).isEqualTo("BadSqlGrammarException"); assertThat(linha).doesNotContain("conclusao", "exception", "INSERT", "senha-de-teste"); } int porta = Objects diff --git a/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java b/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java index 6c4948c3..18c9df3b 100644 --- a/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java +++ b/api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java @@ -43,7 +43,7 @@ void fecharRegistry() { private static NoConcluidoDto evento(String no) { return new NoConcluidoDto(UUID.randomUUID(), UUID.randomUUID(), no, Instant.now(), - new JsonMapper().readTree("{\"resumo\":\"conteudo reservado\"}"), null, null, null, null, null); + new JsonMapper().readTree("{\"resumo\":\"conteudo reservado\"}"), null, null, null, null, null, null); } @ParameterizedTest @@ -96,6 +96,7 @@ void falhaDeBancoOuCommitPropagaSemConteudoERestauraCorrelacao(boolean commit) t var json = new JsonMapper().readTree(linha); assertThat(json.path("job_id").asString()).isEqualTo(Objects.requireNonNull(evento.job_id()).toString()); assertThat(json.path("extra").path("no").asString()).isEqualTo("decisao"); + assertThat(json.path("extra").path("classe_falha").asString()).isEqualTo(falha.getClass().getSimpleName()); verifyNoInteractions(this.emissores); } finally { diff --git a/docs/decisoes/dec-096.md b/docs/decisoes/dec-096.md index 6c095db9..3c353776 100644 --- a/docs/decisoes/dec-096.md +++ b/docs/decisoes/dec-096.md @@ -13,7 +13,7 @@ O codegen grava os artefatos no Postgres e publica `no-concluido` com referênci 3. O índice único de `trilhas_auditoria.evento_id` e o `INSERT ... ON CONFLICT DO NOTHING` tornam a gravação idempotente. Se o commit ocorrer e a confirmação da mensagem se perder, a reentrega encontra a linha existente e não a duplica. 4. A trilha e o resultado têm consumidores próprios. `simulacao-concluida` é consumido pela fila `simulacao-concluida.api` e pode levar o job à decisão do usuário enquanto a gravação da trilha falha. A trilha pode chegar depois do resultado e completar suas referências. 5. O contador `no.concluido.consumo`, exposto como `no_concluido_consumo_total`, mede tentativas com a tag `resultado` em `gravado`, `duplicado`, `descartado` ou `falha`. Uma reentrega já gravada é `duplicado`, não outra gravação. Descartes de validação continuam sem exceção. JSON rejeitado pelo conversor antes de entrar no consumidor não faz parte deste contador. -6. A falha é registrada com `job_id`, o nó em `extra.no` e o resultado, sem conclusão nem exceção original que possa conter artefatos. Não há identificadores de negócio nas tags da métrica. +6. A falha é registrada com `job_id`, o nó em `extra.no`, o resultado e o nome simples da classe da exceção em `extra.classe_falha`, que distingue, por exemplo, uma referência inexistente de um banco indisponível. O log não traz a conclusão nem a mensagem ou a cadeia da exceção original, que podem conter artefatos. Não há identificadores de negócio nas tags da métrica. ## Consequências