Pular para conteúdo

GraalVM native-image — build e diagnóstico

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-21

Como o binário native do integrador se builda, onde mora a metadata e como se diagnostica um gap. Por que o integrador roda native e jar, a medição e a ordem da troca estão na decisão 0028; a compilação nasceu na 0020. A regra da casa é a norma de build native.

Quem builda

  • Produção: o job build_native do .github/workflows/pipeline.yml builda o Dockerfile.native num runner do GitHub e publica fonte.xadm.biz/xadm/integrador-server:native-amd64; o Coolify só puxa. O job roda quando native está no build.targets do docs/app.json (ou no trailer Deploy: da tag).
  • O container não lê o ~/.m2: versão de lib da casa ainda não publicada no registro quebra o build do Dockerfile.native. Local, o nativeCompile resolve pelo mavenLocal.

Comandos locais

export JAVA_HOME="C:/Dev/graalvm-jdk-25.0.3"   # GraalVM 25, com native-image

./gradlew nativeCompile -PnativeQuick   # -Ob: loop de diagnóstico (~5 min, pico de ~6 GB)
./gradlew nativeCompile                 # -Os: o perfil da release
# → build/native/nativeCompile/application(.exe)

docker build -f Dockerfile.native -t integrador-server:native .   # o que o CI builda (Linux)

O bloco configure<GraalVMExtension> do build.gradle.kts fixa o nome application, -Ob/-Os, --gc=serial, -H:IncludeLocales=pt-BR e -march=compatibility; -PnativeCI limita o builder no runner. O nativeCompile roda com o configuration cache.

Onde mora a metadata

  • Views JTE: geradas no build pela extensão NativeResourcesExtension do plugin JTE. Nada à mão, e o pipeline.yml confere a cobertura.
  • Sentry e version.properties: vêm da xadm-comum-web.
  • src/main/resources/META-INF/native-image/br.com.xadm/integrador/:
    • reflect-config.json — os appenders do logback que o boot instancia por reflexão (ConsoleAppender, RollingFileAppender, TimeBasedRollingPolicy, PatternLayoutEncoder) e o io.sentry.logback.SentryAppender, cujos <minimumEventLevel>/<minimumBreadcrumbLevel> o Joran resolve por reflexão. Sem o appender do Sentry registrado, o native loga Could not find an appropriate class for property [...] no boot e ignora a config do appender — o SENTRY_ENVIRONMENT do logback.xml deixa de valer (medido no central-backend).
    • resource-config.json — duas entradas, e a segunda é do próprio SDK, não nossa:
      • firebase-admin-sdk.json, lido por getResourceAsStream em runtime. Desde a decisão 0030 a credencial chega por PUSH_CREDENTIALS_PATH (secret file), e esta entrada fica por cobrir o caminho de teste.
      • admin_sdk.properties, recurso na raiz do firebase-admin-9.2.0.jar, lido no <clinit> de com.google.firebase.internal.SdkUtils (só para carimbar a versão do SDK no header HTTP). Sem ele o primeiro FirebaseMessaging.send() morre com ExceptionInInitializerError (NullPointerException: Failed to load: admin_sdk.properties) e todos os seguintes com NoClassDefFoundError: Could not initialize class FirebaseMessagingClientImpl — a JVM marca a classe como erroneous e nunca retenta. Medido em produção na v1.7.2 native (GlitchTip 1299/1300, Sul Plata): push 100% morto, do boot ao restart. O FirebaseNativeResourceConfigTest guarda as duas entradas, porque na JVM elas vêm do jar sem configuração nenhuma — nenhum teste normal falha quando somem da imagem.
  • reflect-config.json — família @Key do firebase-admin, toda com allDeclaredFields. O google-http-client serializa e parseia por reflexão (ClassInfo lê os declared fields, inclusive private final); sem entrada, o native poda o campo e o dado some sem erro algum. Na JVM nada falha, então nenhum teste normal pega — o FirebaseNativeReflectConfigTest guarda a lista. Medido em produção na v1.7.3 native (GlitchTip 1310, Sul Plata). São duas pernas:
    • request — Message, Notification, AndroidConfig, AndroidNotification, AndroidFcmOptions, ApnsConfig, ApsAlert, ApnsFcmOptions, Webpush*, FcmOptions, LightSettings*: sem elas o corpo sai {"message":{}} sem topic e o FCM responde 400 INVALID_ARGUMENT "Recipient of the message is not set". Assinatura do gap: o build() valida o tópico no cliente, então um 400 de destinatário ausente depois de um build() ok é campo podado, não tópico vazio. (Aps/CriticalSound/WebpushNotification estão na lista só por simetria — não têm @Key, serializam via getFields() num Map.)
    • response — internal/MessagingService{,Error}Response e AbstractPlatformErrorHandler$PlatformError{,Response}. Sem a 1ª, send() devolve messageId nulo até em sucesso; sem a 2ª, getMessagingErrorCode() volta nulo, o FcmPushService nunca classifica o erro como transitório e o retry não acontece; sem as duas últimas, a mensagem do Google se perde e sobra o dump genérico Unexpected HTTP response with status: NNN — foi essa assinatura no 1310 que denunciou a poda, porque na JVM a mensagem seria a curta (Recipient of the message is not set.).
  • Locale pt-BR — são duas coisas, e as duas precisam existir:
    • os dados do locale vêm do binário, por -H:IncludeLocales=pt-BR no build.gradle.kts (a GraalVM embarca só en). Quem formata com Locale explícito já funcionava sem mais nada;
    • o default locale da JVM vem do ambiente. A imagem do jar (eclipse-temurin) traz LANG/LANGUAGE/LC_ALL; a base do native (debian:12-slim) não traz nada, então quem formata sem locale explícito caía no default do sistema. Por isso o Dockerfile.native gera pt_BR.UTF-8 e exporta as três envs — paridade com o jar. Sem elas o desvio é silencioso: número, data e moeda mudam de forma sem erro nenhum.
  • Corpo JSON lido para record: pelo JsonMapper do Micronaut Serde (@Serdeable), nunca pelo ObjectMapper do Jackson, que lê por reflexão e o AOT poda.

Diagnosticar um gap de runtime

  1. O compile passou e o boot quebra: quase sempre é MissingReflectionRegistrationError ou recurso ausente, e a stack trace nomeia a classe ou o recurso.
  2. Reflexão: acrescente a classe ao reflect-config.json. Recurso lido por caminho montado em runtime: acrescente o pattern ao resource-config.json. Valide o JSON depois de editar: \Q...\E é regex-quote e precisa da barra DOBRADA no arquivo ("\\Qnome\\E"); com uma barra só, \Q não é escape de JSON válido e o native-image descarta o arquivo inteiro — você perde também as entradas que já funcionavam, e o binário sai pior do que sairia sem a edição. Guarde com um teste que faça parse do JSON, não contains() no texto cru: o contains() passa verde exatamente no arquivo quebrado (é o que o FirebaseNativeResourceConfigTest faz).
  3. Rota com 404 só no native: o AOT podou. Compare com o jar pela mesma checagem antes de concluir.
  4. Gap dentro de um <clinit> de lib tem assinatura própria: um ExceptionInInitializerError com a causa real (a classe ou o recurso que faltou) e depois N NoClassDefFoundError: Could not initialize class X sem causa nenhuma. O primeiro evento é o único que diz o que faltou — se o GlitchTip mostra só NoClassDefFoundError, procure o evento anterior do mesmo trace_id.
  5. catch (Exception) não pega nada disso — os dois são LinkageError. Capture LinkageError (não Throwable: OutOfMemoryError e StackOverflowError devem subir, engoli-los esconde a JVM morrendo) em dois lugares, e os dois importam:
    • na borda fire-and-forget (thread virtual solta, listener de evento) — senão o gap vira evento FATAL pelo UncaughtExceptionHandler, um por tentativa, em vez de log tratado;
    • em toda compensação try/catch no caminho, e é aqui que o estrago é silencioso. No DeduplicatingPushService a compensação inteira — liberar a reivindicação de dedup e gravar a linha de erro em push_enviada — estava atrás de catch (RuntimeException). O LinkageError pulou tudo: os pushes perdidos não deixaram traço nenhum na auditoria do próprio app, nem como sucesso nem como erro, e a reivindicação ficou pendurada os 10 min da janela, fazendo um retry saudável da mesma mensagem pela perna jar ser descartado como duplicado. Alargar só a captura de fora e não a compensação de dentro mantém a assimetria que cria o estado sujo.
  6. Não chame Sentry.captureException junto de LOG.error. O SentryAppender do logback.xml publica a partir de ERROR; os dois juntos criam duas issues por ocorrência.
  7. Rebuild com -PnativeQuick e repita o smoke.

Smoke local do binário

Sobe contra o Postgres de dev, com a auth em bypass:

docker compose -f docker-compose.dev.yml up -d --wait postgres
MICRONAUT_ENVIRONMENTS=dev ./build/native/nativeCompile/application
curl -s localhost:8080/health          # {"status":"UP",…,"flavor":"native"}

Rotas-chave, conferidas contra o jar: as views de listagem, PUT /api/v1/xadm com um payload de docs/anexos/exemplos-payload/, POST /api/v1/powersync, POST /api/v1/heartbeat (sem central, responde 503 heartbeat_desligado), GET /export/xlsx/{table} (ex. municipio) e GET /api/health.

O envio FCM real não roda em dev (push.enabled=false); prova-se na instância piloto da troca. Foi exatamente esse buraco que deixou a v1.7.2 native ir a produção com o push morto (GlitchTip 1299/1300): compile, boot, health e rotas passaram — o <clinit> do FirebaseMessagingClientImpl só roda no primeiro send(). Um smoke de push contra o canal isolado do e2e (PUSH_EMPRESA=test → tópicos bl_liberado_test/nota_venda_emitida_test, que produção não assina — ver a constante EMPRESA_TEST do FcmPushService) é o único jeito de exercitar esse caminho antes do deploy. O roteiro de push com app real está em push-fcm-testar.