Ação assíncrona
Quando usar: a operação demora mais do que uma requisição HTTP deve durar (varredura, geração de relatório, chamada a terceiro), ou precisa sobreviver a um restart do pod. A regra é simples: requisição HTTP não fica aberta esperando trabalho.
O padrão da plataforma: a rota responde 202 com o corpo {"action": {…}} e um Location da rota de
status; quem executa é um Runner dentro do serviço, com lease, retry e desfecho registrado.
O descritor
nnp/api.yaml — a rota async e a rota de status (obrigatória quando existe rota async):
actions: {status_route: actions.get}
routes:
- {id: items.scan, method: POST, path: "/items/{item_id}/scan", permission: sample.item.scan, idempotency: required, mode: async, max_body_bytes: 4096}
- {id: actions.get, method: GET, path: "/actions/{action_id}", permission: sample.item.read}
O evento terminal da operação é uma trilha de audit declarada — ele sai no audit.yaml, não no
api.yaml:
trails:
operations:
audience: tenant
events:
item.scan: {schema_version: 1, description: Varredura concluída ou falha, payload_schema: audit/operations/item.scan.v1.schema.json}
Aceitar: barato e transacional
java/src/main/java/.../app/ScansController.java:
@NnsRoute("items.scan")
ResponseEntity<byte[]> scan(
Ctx ctx, ServiceContext sc, ServiceChain.Dispatch d, Match m, NnsBody body) {
String id = m.params().get("item_id");
return service.mutation(
ctx,
sc,
d,
body,
(tx, b) -> {
if (!isBlank(b) && decodeEmpty(b) == null) {
throw Wire.invalidBody();
}
String tenant = sc.requireTenantId();
Item it = ItemStore.get(ctx, tx, tenant, id, true);
if (service.scanBusy(ctx, it)) {
throw SampleService.scanInProgress(it);
}
Action act =
Actions.newAction(
ctx, service.actionConfig, SampleService.SCAN_TYPE, Map.of("item_id", it.id()))
.withResource("item", it.id());
service.actions.create(ctx, tx, act);
ItemStore.markScanPending(ctx, tx, tenant, it.id(), act.id(), act.createdAt());
return Actions.acceptedResponse(act);
});
}
O aceite grava três coisas na mesma transação: a reserva da Idempotency-Key, a Action
(nn_actions) e o vínculo no item. Repetir a chamada com a mesma chave devolve a mesma resposta
— a Action original — em vez de criar uma segunda varredura.
O estado da operação é uma leitura, com a vista montada a partir da Action:
@NnsRoute("actions.get")
ResponseEntity<byte[]> action(Ctx ctx, ServiceContext sc, Match m) {
Action a = service.actions.get(ctx, sc.requireTenantId(), m.params().get("action_id"));
return Wire.json(200, Map.of("action", actionView(a)));
}
Executar: o handler
O tipo da Action é a permissão qualificada da rota que a aceita (sample.item.scan), e é por ele
que o Runner encontra o código:
public static final String SCAN_TYPE = SampleModule.ID + ".item.scan";
java/src/main/java/.../app/SampleService.java:
/** O handler da Action. Idempotente: repetido depois de crash, regrava o mesmo resultado. */
public Map<String, Object> runScan(Ctx ctx, Action act) {
String itemId = itemIdOf(act.input());
if (itemId.isEmpty()) {
throw ApiError.of(Code.VALIDATION_FAILED, "scan_input_invalid", "Entrada da varredura sem item_id.");
}
boolean mine;
try {
mine = ItemStore.markScanRunning(ctx, db, act.tenantId(), itemId, act.id(), now());
} catch (RuntimeException e) {
throw dbUnavailable(); // 503 dependency_unavailable/database_unavailable, retryable
}
if (!mine) {
// Item apagado, ou a varredura dele é outra: falha DEFINITIVA, sem retry.
throw ItemStore.notFound();
}
progress(ctx, "varrendo o item");
sleep(ctx, scanDelay); // NnsBackground.sleep: acorda no cancelamento
Item it = ItemStore.get(ctx, db, act.tenantId(), itemId, false);
List<Finding> findings = ScanEngine.checks(it);
int score = ScanEngine.score(findings);
boolean recorded =
ItemStore.recordScan(
ctx, db, act.tenantId(), itemId, act.id(), ScanEngine.ENGINE, config.releaseId(),
findings, score, now());
if (!recorded) {
throw ItemStore.notFound();
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("item_id", itemId);
out.put("engine", ScanEngine.ENGINE);
out.put("checks", findings.size());
out.put("warnings", ScanEngine.warnings(findings));
out.put("score", score);
out.put("marker", SampleModule.MARKER);
out.put("completed_by", config.releaseId());
return out;
}
Quatro regras que fazem a diferença:
- A posse é um predicado do banco (
scan_action_id = <a Action>): repetir a execução depois de um crash regrava o mesmo resultado em vez de abrir uma segunda varredura. - Erro classificado: o que pode melhorar (banco fora) é
dependency_unavailablecomretryable: true; o que não pode (item apagado) é 404 — o Runner não insiste. - A espera é do SDK (
NnsBackground.sleep(ctx, d)): ela acorda quando o contexto é cancelado, e o Runner devolve a Action em vez de segurá-la até o fim do lease. - O que o handler devolve é o corpo do evento terminal — é ele que fica no
resultda Action e no payload do eventooperations/item.scan.
Registrando o Runner
java/src/main/java/.../app/SampleConfiguration.java:
@Bean
ActionRunner sampleActionRunner(
ActionStore store, NnsConfig config, NnsModule module, SampleService service,
TxAuditEmitter emitter, Logger logger) {
ActionRunner.Builder b =
ActionRunner.builder(store, config.releaseId())
.handler(SampleService.SCAN_TYPE, service::runScan)
.logger(logger.with("worker", "action-runner"));
if (emitter.transactional() && module.audit().isPresent()) {
b.emitter(emitter).audit(module.audit().get());
}
return b.build();
}
/** O Runner roda em TODA réplica de toda release, como laço longo do ciclo de vida. */
@Bean
SmartInitializingSingleton sampleActionRunnerLoop(
ObjectProvider<ActionRunner> runner, ObjectProvider<NnsBackground> background) {
return () -> {
ActionRunner r = runner.getIfAvailable();
NnsBackground bg = background.getIfAvailable();
if (r != null && bg != null) {
bg.loop("action-runner", r::run);
}
};
}
Mais o Reaper (fecha as Actions de uma release que saiu do ar) e o contador do /ready:
@Bean
ActionReaper sampleActionReaper(ActionStore store, NnsConfig config, RoleWatcher role, Logger logger) {
return ActionReaper.of(store, config.releaseId(), () -> role.refresh().live())
.withLogger(logger.with("worker", "action-reaper"))
.withAdoptable(List.of(SampleService.SCAN_TYPE));
}
@Bean
NnsPendingActions samplePendingActions(ActionStore store, NnsConfig config) {
return ctx -> store.pending(ctx, config.releaseId());
}
O cliente vê assim
POST /v1/sample/items/0be1d3c6-6f3e-4a6b-a4f5-2a2f2a9f0d10/scan
Idempotency-Key: 8e14f0c6-...
202 Accepted
Location: /v1/sample/actions/1c9b0b7e-...
{"action":{"id":"1c9b0b7e-...","type":"sample.item.scan","status":"pending", ...}}
GET /v1/sample/actions/1c9b0b7e-...
200 OK
{"action":{"id":"1c9b0b7e-...","status":"succeeded","output":{"item_id":"0be1d3c6-...","score":100, ...}}}
Como testar
Em MySQL real, com Runner de verdade (java/src/test/java/.../app/ScanActionMysqlTest.java):
@Test
void runnerClosesTheActionWithTheResultAndTheItemKeepsIt() {
Item it = newItem("Cartão");
Action act = acceptScan(it);
assertThat(act.status()).isEqualTo(Status.PENDING);
assertThat(runner.processOnce(CTX)).isEqualTo(1);
Action done = actions.get(CTX, TENANT, act.id());
assertThat(done.status()).isEqualTo(Status.SUCCEEDED);
assertThat(done.result().get())
.containsEntry("item_id", it.id())
.containsEntry("engine", ScanEngine.ENGINE)
.containsEntry("score", 100.0)
.containsEntry("completed_by", RELEASE);
assertThat(ItemStore.get(CTX, db, TENANT, it.id(), false).scanStatus()).isEqualTo(Scan.SUCCEEDED);
}
@Test
void aRetryAfterACrashCompletesTheScanInsteadOfDuplicatingIt() {
Item it = newItem("Duplicado");
Action act = acceptScan(it);
// A tentativa anterior marcou o item como `running` e morreu antes de gravar o resultado.
assertThat(ItemStore.markScanRunning(CTX, db, TENANT, it.id(), act.id(), act.createdAt())).isTrue();
Map<String, Object> result = service.runScan(CTX, act);
assertThat(result).containsEntry("score", 100).containsEntry("item_id", it.id());
}
Erros comuns
- Fazer o trabalho dentro da requisição e responder 200 no fim: a requisição fica presa, o gateway corta no prazo e o cliente não sabe se a operação aconteceu.
- Esquecer
actions.status_route— a partida recusa: uma rota async sem rota de status não teria como ser acompanhada. - Responder 202 sem
Location— useActions.acceptedResponse(act), que monta os dois. - Não classificar o erro — tudo virando 500 faz o Runner desistir depois das tentativas de algo que
era só indisponibilidade momentânea; e um
retryable: truenum erro definitivo faz o contrário. - Criar a Action fora da transação do vínculo — sobra Action sem dono (ou item marcado sem Action) quando algo falha no meio.
- Fazer
Thread.sleep— o desligamento não espera por você e o lease vence. UseNnsBackground.sleep(ctx, …), que devolve o controle no cancelamento. - Registrar o mesmo tipo em dois Runners da mesma release — o tipo passa a ter dois donos.
- Rodar o Runner só na release ativa — o Runner roda em toda réplica de toda release: a Action pertence a uma release, e é ela que a executa.
- Devolver dado sensível no resultado — o resultado vai para o cliente e para a trilha de audit.
Esta página ajudou?
Reportar um problema nesta páginaNão envie senhas, chaves, tokens ou dados de clientes.