Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/src/main/java/synapse/api/core/metrics/AppMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions api/src/main/java/synapse/api/job/ConsumidoresJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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");
Expand All @@ -290,10 +297,26 @@ 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")
.addKeyValue("classe_falha", ex.getClass().getSimpleName())
.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
Expand Down
9 changes: 9 additions & 0 deletions api/src/main/java/synapse/api/job/ExcecoesJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions api/src/main/java/synapse/api/job/JobEventosService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ private static NoConcluidoDto decisao(UUID jobId, @Nullable UUID resultadoId) th
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, UUID.randomUUID());
when(servico.registrarNoConcluido(eq(jobId), any(), eq(EtapaDoGrafo.DECISAO), eq(evento))).thenReturn(busca);
Expand Down Expand Up @@ -109,7 +111,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, null);
when(servico.registrarNoConcluido(eq(jobId), any(), eq(EtapaDoGrafo.DECISAO), eq(evento)))
Expand Down
97 changes: 94 additions & 3 deletions api/src/test/java/synapse/api/job/NoConcluidoConsumidorTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,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)
Expand Down Expand Up @@ -125,6 +125,97 @@ 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(json.path("extra").path("classe_falha").asString()).isEqualTo("BadSqlGrammarException");
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();
Expand Down Expand Up @@ -300,11 +391,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
Expand Down
107 changes: 107 additions & 0 deletions api/src/test/java/synapse/api/job/NoConcluidoObservabilidadeTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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, 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");
assertThat(json.path("extra").path("classe_falha").asString()).isEqualTo(falha.getClass().getSimpleName());
verifyNoInteractions(this.emissores);
}
finally {
MDC.clear();
}
}

}
Loading
Loading