Automation
Motor de execução durável para fluxos de trabalho: cada passo é registrado antes de rodar, retentado com backoff limitado e recuperável por replay. Esta página documenta as garantias de entrega, os limites numéricos do executor e o comportamento observado quando um passo, um worker ou uma região falha.
Introdução
Automation é o serviço da Anthares responsável por executar fluxos de trabalho que não podem falhar em silêncio. Um fluxo aqui é uma sequência declarada de passos — chamadas HTTP, escritas no Postgres gerenciado, publicações em fila, invocações de bot — cujo estado intermediário é persistido no execution log antes de cada tentativa. Isso muda a pergunta operacional padrão: em vez de "o job rodou?", a pergunta passa a ser "em qual passo a execução parou, com qual erro, e a partir de onde ela pode ser retomada sem duplicar efeitos já aplicados".
A distinção mais importante para quem projeta automações na plataforma é entre entrega e efeito. O barramento de eventos entrega ao-menos-uma-vez; o executor não tem como impedir que um passo seja tentado duas vezes quando um worker morre entre a execução e a confirmação. O que a plataforma oferece é o mecanismo para tornar a segunda tentativa inofensiva: chave de idempotência com janela de deduplicação, gravação do resultado do passo antes do ack e compensação explícita quando o passo já aplicou efeito irreversível. Toda decisão de design descrita adiante decorre disso.
O escopo é operacional. Assume-se um projeto Anthares com Postgres gerenciado e credenciais de CLI válidas. Inferência de modelos é coberta em AI Apps e ingestão analítica em lote em Data Platforms; aqui tratamos do caminho de execução transacional, de milissegundos até 72 horas de wall clock.
Uma execução pode permanecer viva por até 72 horas contando esperas (sleep, waitForEvent). Passado esse prazo, o executor marca a execução como expired e dispara as compensações registradas. Fluxos de duração maior devem ser quebrados em execuções encadeadas por evento.
Visão geral
O serviço se divide em quatro planos com ciclos de vida independentes. O plano de ingestão recebe gatilhos externos (webhooks HTTP, publicações no barramento, disparos de cron) e os normaliza em envelopes de evento. O plano de agendamento decide qual execução pode avançar agora, respeitando limites de concorrência e ordenação por partição. O plano de execução roda o código do passo em workers isolados. O plano de durabilidade mantém o log append-only em Postgres, que é a única fonte de verdade sobre o que já aconteceu.
Essa separação existe porque as três primeiras camadas são reinicializáveis e a quarta não é. Se todos os workers de uma região caírem, nenhuma execução é perdida: elas ficam pending no log e são reclamadas por workers de outra região assim que o lease da execução expira, com prazo padrão de 30 segundos. Se o log for corrompido, nenhuma garantia se sustenta — por isso ele roda em Postgres com replicação síncrona para pelo menos uma réplica e ponto de recuperação (RPO) alvo de zero para commits confirmados.
concurrency e partitionKey, entrega leases aos workers.Os objetos manipulados no dia a dia estão abaixo. Todos têm identificador estável e aparecem nas métricas e nos logs com o mesmo nome, o que permite correlacionar um alerta a um recurso sem tradução mental.
| Objeto | Prefixo do ID | Descrição |
|---|---|---|
workflow | wf_ | Definição versionada de passos. Imutável após publicada; alterações geram nova versão. |
run | run_ | Uma execução concreta de uma versão de workflow, com seu próprio log de passos. |
step attempt | att_ | Tentativa individual de um passo. Uma por retry, nunca sobrescrita. |
trigger | trg_ | Regra que liga uma origem (evento, cron, webhook) a um workflow. |
queue | q_ | Canal de trabalho com política própria de concorrência, ordenação e DLQ. |
connector | con_ | Credencial e configuração de acesso a um serviço externo ou interno. |
Primeiros passos
A CLI a44 publica definições, dispara execuções manuais, lê logs e faz replay. Tudo isso existe também na API REST em https://api.anthares44.dev/v1/automation, com o mesmo formato de payload — a CLI encurta o ciclo local, não esconde capacidades.
A sequência mínima até uma execução real tem quatro passos: criar o workflow, publicar, disparar e inspecionar. O detalhe a notar é que a44 automation run exige --idempotency-key: a plataforma recusa disparos manuais sem chave para que testes repetidos não gerem execuções duplicadas por engano.
Autenticar e selecionar o projeto
O token fica em
~/.a44/credentialscom escopo por projeto. Tokens de CI devem usar--scope automation:writeapenas, sem acesso a segredos.Declarar o workflow
Um arquivo YAML por workflow, versionado no seu repositório. O campo
versioné obrigatório e precisa ser incrementado a cada mudança de passos.Publicar
A publicação valida o grafo, checa referências a conectores e recusa definições com passo sem
timeoutdeclarado.Disparar e inspecionar
O disparo retorna o
run_idimediatamente; a execução é assíncrona. O log de passos fica disponível em streaming.
npm i -g @anthares44/cli
a44 auth login --project pj_checkout_prod
a44 automation publish ./workflows/invoice-settle.yaml
a44 automation run wf_invoice_settle \
--input '{"invoiceId":"inv_8812"}' \
--idempotency-key "manual-inv_8812-2026-07-19"
a44 automation logs run_01J9XQ --follow
name: invoice-settle
version: 3
trigger:
event: billing.invoice.finalized
concurrency:
max: 25
partitionKey: "{{ input.invoiceId }}"
steps:
- id: reserve-funds
uses: connector/payments.authorize
timeout: 15s
retry: { max: 5, strategy: exponential-jitter }
compensate: connector/payments.void
- id: mark-paid
uses: sql/execute
timeout: 5s
query: "update invoices set status='paid' where id = $1"
a44 automation publish --dry-run valida o grafo e imprime o plano de execução sem criar versão. Use no pull request; o custo é uma chamada de API e evita versões órfãs no histórico do workflow.
Arquitetura
O caminho de uma execução começa quando o plano de ingestão grava o envelope e termina quando o último passo é confirmado no log. Entre os dois extremos, o agendador é o único componente com autoridade para conceder o direito de rodar um passo. Ele o faz emitindo um lease com prazo, e não um comando: o worker que recebe o lease pode morrer, e o agendador reconcede o mesmo passo depois que o prazo vence. Essa escolha é o que torna o sistema tolerante a perda de worker sem coordenação distribuída cara.
O preço dessa escolha é explícito: entre o vencimento do lease e a reconcessão, um worker zumbi pode ainda estar rodando o passo. Não há como distinguir "worker morto" de "worker lento" sem falsos positivos. Portanto o executor assume que a sobreposição acontece e exige idempotência do passo. Se o passo não puder ser idempotente, ele precisa declarar compensate para que o efeito duplicado seja desfeito, ou usar exclusive: true, que troca disponibilidade por segurança e bloqueia a reconcessão até confirmação humana.
Lease
Direito temporário de rodar um passo. Padrão de 30 s, renovado por heartbeat a cada 10 s.
Execution log
Append-only. Uma linha por tentativa, resultado até 256 KB inline e acima disso por referência ao storage.
Reconcessão
Após o vencimento do lease, o passo volta à fila. Exige idempotência ou exclusive: true.
O log é particionado por mês e por projeto no Postgres. Cada tentativa de passo ocupa uma linha com run_id, step_id, attempt, idempotency_key, status e o resultado serializado limitado a 256 KB. Resultados maiores são gravados no storage e a linha guarda apenas a referência, o que mantém a tabela dentro de um perfil de índice previsível. A partir de aproximadamente 40 milhões de tentativas por mês em um único projeto, a latência de concessão de lease começa a subir e a recomendação passa a ser separar domínios em projetos distintos.
select step_id,
attempt,
status,
error_code,
finished_at - started_at as duration
from automation.step_attempts
where run_id = 'run_01J9XQ'
order by attempt asc, started_at asc;
Workflows
Um workflow pode ser declarado em YAML, como no exemplo anterior, ou em código com o SDK. A versão em código é preferível quando os passos têm lógica condicional real, porque o YAML expõe apenas if com expressões simples e não permite loops arbitrários. Nas duas formas, o modelo de execução é idêntico: cada chamada a step.run() consulta o log antes de executar e, se já houver resultado confirmado para aquele step_id naquele run_id, retorna o valor gravado sem rodar o corpo de novo. É isso que torna o replay determinístico.
Por consequência, o corpo do workflow fora dos passos precisa ser determinístico. Date.now(), Math.random() e leituras diretas de rede fora de um step.run() produzem divergência entre a execução original e o replay, e o executor aborta com E_NONDETERMINISTIC_REPLAY quando detecta que a sequência de step_id observada difere da registrada. Use ctx.now() e ctx.random(), que gravam o valor sorteado no log na primeira execução.
import { workflow, step } from '@anthares44/automation';
export default workflow('tenant-onboard', { version: 4 }, async (ctx, input) => {
const tenant = await step.run('create-tenant', { timeout: '10s' }, () =>
api.tenants.create({ name: input.name, requestId: ctx.idempotencyKey })
);
await step.run('provision-db', {
timeout: '120s',
retry: { max: 4, strategy: 'exponential-jitter' },
compensate: () => api.databases.drop(tenant.dbId)
}, () => api.databases.create({ tenantId: tenant.id }));
await step.sleep('cooldown', '30s');
return { tenantId: tenant.id, at: ctx.now() };
});
from anthares44.automation import workflow, step
@workflow("tenant-onboard", version=4)
async def onboard(ctx, input):
tenant = await step.run(
"create-tenant", timeout="10s",
fn=lambda: api.tenants.create(name=input["name"],
request_id=ctx.idempotency_key),
)
await step.run(
"provision-db", timeout="120s",
retry={"max": 4, "strategy": "exponential-jitter"},
compensate=lambda: api.databases.drop(tenant["db_id"]),
fn=lambda: api.databases.create(tenant_id=tenant["id"]),
)
await step.sleep("cooldown", "30s")
return {"tenant_id": tenant["id"], "at": ctx.now()}
Compensação de passo falho
Quando um passo esgota suas tentativas, o executor não abandona a execução no meio. Ele percorre os passos já concluídos em ordem inversa e invoca o compensate de cada um que tenha declarado. Esse é o padrão saga: em vez de rollback transacional — impossível quando os efeitos estão em serviços externos —, cada passo carrega a instrução de como desfazer o que fez. A compensação tem orçamento próprio de retry, fixo em 3 tentativas com intervalo de 5 s, 20 s e 60 s.
Se uma compensação também falhar depois das 3 tentativas, a execução entra no estado compensation_failed e para. Esse estado nunca é limpo automaticamente, porque significa que o sistema ficou com efeito parcial aplicado e não sabe como reverter — exatamente o caso que exige decisão humana. Um alerta é emitido e a execução aparece em a44 automation runs --state compensation_failed.
Entre a conclusão de um passo e sua compensação existe uma janela em que o efeito está visível para terceiros. Se o passo envia e-mail, cobra cartão ou publica em canal externo, a compensação só consegue emitir um contra-efeito — estorno, retratação —, nunca apagar o original. Ordene os passos irreversíveis o mais tarde possível no fluxo.
Eventos e entrega
O barramento de eventos entrega ao-menos-uma-vez. Isso não é uma limitação temporária: é a única garantia possível quando produtor, rede e consumidor podem falhar independentemente e o consumidor precisa confirmar recebimento. A duplicata aparece principalmente em dois momentos — quando o produtor faz retry após um timeout de rede em que o servidor já havia gravado, e quando o consumidor processa mas morre antes do ack.
A entrega exatamente-uma-vez que a plataforma oferece é, com precisão, "ao-menos-uma-vez na entrega e no-máximo-uma-vez no efeito". Ela é obtida com a idempotency_key do envelope: o plano de ingestão mantém um índice único sobre (project_id, idempotency_key) com janela de retenção de 24 horas. Um segundo envelope com a mesma chave dentro da janela é aceito com HTTP 200 e o run_id original, sem criar execução nova. Fora da janela, a chave é reciclada e o evento cria execução nova — por isso chaves devem ser derivadas do dado, não do tempo.
{
"id": "evt_01J9XQ7M4K",
"type": "billing.invoice.finalized",
"idempotency_key": "invoice:inv_8812:finalized:v1",
"partition_key": "cust_4471",
"occurred_at": "2026-07-19T14:02:11.884Z",
"attempt": 1,
"data": { "invoiceId": "inv_8812", "amountCents": 249900 }
}
| Garantia | Como se obtém | Custo |
|---|---|---|
| Ao-menos-uma-vez | Padrão. Ack após persistência do resultado do passo. | Duplicatas possíveis; o passo precisa ser idempotente. |
| No-máximo-uma-vez | retry.max: 0 no passo. | Perda silenciosa em qualquer falha transitória. Use só para telemetria descartável. |
| Efeito único | idempotency_key derivada do dado + índice único no destino. | Cerca de 2 ms de latência extra por passo e retenção de 24 h de chaves. |
| Ordem por chave | partition_key com concorrência 1 na partição. | Uma partição lenta bloqueia todos os eventos daquela chave. |
Usar Date.now() na chave garante que toda retry do produtor gere uma chave nova e, portanto, uma execução duplicada — o oposto do objetivo. Derive a chave do identificador do recurso e da transição de estado, como invoice:inv_8812:finalized:v1.
Triggers
Um trigger é a regra que liga uma origem a uma versão de workflow. Existem quatro tipos: event (assinatura no barramento), webhook (endpoint HTTP dedicado), schedule (cron) e manual (CLI ou API). Um mesmo workflow pode ter vários triggers ativos; o envelope carrega trigger_id para que o fluxo saiba de onde veio sem inspecionar o formato dos dados.
Triggers de evento aceitam filtro declarativo avaliado na ingestão, antes de qualquer execução ser criada. Filtrar ali é mais barato do que iniciar a execução e sair no primeiro if: o descarte custa um incremento de contador, enquanto uma execução abortada consome linha no log e concessão de lease. Acima de cerca de 200 eventos por segundo, essa diferença domina o custo do projeto.
event
Assina um tipo do barramento. Suporta curinga no último segmento, como billing.invoice.*. Filtro avaliado antes da execução.
webhook
Cria endpoint com caminho estável e segredo próprio. Responde 202 em até 200 ms e executa fora do ciclo da requisição.
schedule
Expressão cron com fuso explícito. Garantia de instância única por ocorrência via lease determinístico no agendador.
- id: trg_refund_large
type: event
event: billing.refund.requested
filter: "data.amountCents > 500000 && data.currency == 'BRL'"
workflow: wf_refund_review
deliver:
maxAge: 6h # evento mais velho que isso vai direto para a DLQ
dropOnFilterMiss: true
O campo maxAge evita o cenário em que uma fila represada volta a fluir e o sistema executa milhares de automações sobre um estado do mundo que já mudou. Eventos acima do limite vão para a dead letter queue com error_code: E_EVENT_TOO_OLD, disponíveis para inspeção e reenfileiramento seletivo.
Webhooks
Cada trigger de webhook recebe um caminho no formato https://hooks.anthares44.dev/v1/{project}/{trigger} e um segredo de 32 bytes. O endpoint aceita apenas POST com Content-Type: application/json e corpo de até 1 MB; corpos maiores recebem 413 sem consumir cota. A resposta é 202 com o run_id no corpo assim que o envelope é persistido — a execução do workflow não bloqueia a resposta, o que mantém o p99 do endpoint abaixo de 200 ms mesmo com fluxos longos.
A verificação de assinatura de entrada é obrigatória e não pode ser desligada. O emissor calcula HMAC-SHA256 sobre a concatenação timestamp + "." + corpo_bruto e envia o resultado em A44-Signature, com o timestamp Unix em A44-Timestamp. O plano de ingestão recusa com 401 se a assinatura não bater e com 408 se o timestamp divergir do relógio do servidor em mais de 300 segundos — essa janela é o que impede replay de uma requisição capturada. Quem verifica é a plataforma, antes de qualquer envelope ser gravado: o trabalho do lado do emissor é assinar corretamente e, sobretudo, mandar uma chave de idempotência estável, porque é ela que decide se o retry do emissor vira execução nova ou reencontra a original.
import { createHmac } from 'node:crypto';
// a chave sai do recurso e da transicao de estado, nunca do relogio:
// e ela que faz o retry deste POST reencontrar o run original
const key = `invoice:${invoice.id}:${invoice.status}:v1`;
const body = JSON.stringify({ invoiceId: invoice.id, amountCents: invoice.total });
const ts = Math.floor(Date.now() / 1000).toString();
await fetch(HOOK_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'A44-Timestamp': ts,
'A44-Idempotency-Key': key,
'A44-Signature': createHmac('sha256', SECRET)
.update(ts + '.' + body) // mesma string que a ingestao recalcula
.digest('hex'),
},
body,
});
// 202 -> { run_id } primeira vez, execucao criada
// 200 -> { run_id } chave repetida dentro de 24 h, run original
// 408 E_TIMESTAMP_SKEW relogio do emissor fora dos 300 s
# gera cabecalhos validos para o segredo ativo, sem copiar o segredo
a44 automation webhook sign --trigger trg_erp_sync --file payload.json
# A44-Timestamp: 1784500860
# A44-Signature: 5c81...e0
# A44-Idempotency-Key: (ausente — a ingestao vai gerar uma por corpo)
# dispara de verdade e observa a decisao de deduplicacao
a44 automation webhook send --trigger trg_erp_sync --file payload.json \
--idempotency-key "erp:ord_991:shipped:v1" --repeat 2
# 1/2 202 run_01J9XQ created
# 2/2 200 run_01J9XQ deduplicated (idade da chave: 0.4s)
A rotação mantém dois segredos ativos: a44 automation webhook rotate trg_erp_sync cria o novo e aceita o antigo por mais 48 horas. Depois disso o antigo é invalidado sem aviso — se o emissor não migrou, tudo passa a receber 401 e o contador webhook_rejected_total sobe.
Filas
Filas são o mecanismo de controle de vazão entre a ingestão e a execução. Cada fila declara maxConcurrency (execuções simultâneas), ordering (none ou by-partition) e a política de dead letter. Sem fila explícita, o workflow usa a fila padrão do projeto, com concorrência 100 e ordenação none.
Com ordering: by-partition, a plataforma garante que dois eventos com a mesma partition_key nunca executem simultaneamente e sejam processados na ordem em que foram gravados na ingestão. Chaves diferentes seguem em paralelo até o teto de concorrência. A garantia é por chave, não global: não existe ordem total entre partições, e assumir que existe é a causa mais frequente de bug sutil em automações que envolvem duas entidades relacionadas. Se a ordem entre duas entidades importa, elas precisam compartilhar a mesma chave de partição.
| Parâmetro | Padrão | Descrição |
|---|---|---|
maxConcurrency | 100 | Execuções simultâneas na fila. Teto duro de 2.000 por projeto; acima disso a solicitação é recusada com E_QUOTA. |
ordering | none | by-partition serializa por partition_key e reduz a vazão efetiva ao número de chaves distintas ativas. |
maxAttempts | 6 | Tentativas antes de mover para a DLQ, contando a primeira. |
dlq.retention | 14d | Retenção das mensagens mortas. Após o prazo são apagadas de forma irreversível. |
visibilityTimeout | 30s | Prazo do lease. Deve ser maior que o timeout do passo mais lento, senão haverá reconcessão durante execução válida. |
Dead letter queue
Uma mensagem vai para a DLQ quando esgota maxAttempts, quando falha na desserialização do envelope ou quando excede maxAge. A DLQ preserva o envelope original íntegro, o histórico de tentativas e o último erro. Nada é reprocessado automaticamente a partir dela — é uma decisão deliberada, porque reprocessar sem entender a causa costuma reproduzir a falha e consumir a cota de novo.
# agrupa as mortas por código de erro nas últimas 24 h
a44 automation dlq stats q_invoice_settle --since 24h
# inspeciona uma mensagem específica com o histórico de tentativas
a44 automation dlq show dlq_01J9YB2C --with-attempts
# reenfileira só o que falhou por indisponibilidade do parceiro
a44 automation dlq redrive q_invoice_settle \
--filter "error_code == 'E_UPSTREAM_503'" \
--rate 20/s --max 5000
O --rate é obrigatório acima de 500 mensagens. Um redrive sem limite reintroduz de uma vez toda a carga que causou a falha original e costuma derrubar o serviço a jusante pela segunda vez. Comece em 10–20 mensagens por segundo e observe a taxa de erro antes de subir.
Cron jobs
Triggers de schedule aceitam expressão cron de cinco campos mais um fuso horário obrigatório em tz. O fuso é obrigatório porque a alternativa — assumir UTC — produz execuções deslocadas em uma hora duas vezes por ano em fusos com horário de verão, e esse tipo de erro só aparece meses depois em produção. Com tz declarado, a plataforma resolve a ocorrência no calendário local e aplica a política de dstPolicy para horários ambíguos ou inexistentes.
A garantia de instância única funciona assim: para cada ocorrência agendada, o agendador calcula um identificador determinístico {trigger_id}:{occurrence_iso} e tenta inserir esse valor em uma tabela com índice único. Só a inserção vencedora dispara a execução. Como o identificador deriva do horário nominal e não do relógio do processo, dois agendadores que acordem no mesmo instante em regiões diferentes produzem a mesma chave e apenas um vence. Não há eleição de líder nem coordenação adicional.
- id: trg_nightly_reconcile
type: schedule
cron: "15 3 * * *"
tz: America/Sao_Paulo
workflow: wf_reconcile_ledger
dstPolicy: skip-nonexistent # skip-nonexistent | run-once | run-twice
overlap: skip # skip | queue | allow
catchUp:
enabled: true
maxOccurrences: 3 # recupera no máximo 3 ocorrências perdidas
maxAge: 12h
O parâmetro overlap trata a ocorrência que chega com a anterior ainda rodando. skip a registra como skipped_overlap — adequado a jobs de reconciliação, em que duas execuções paralelas disputam lock no Postgres. queue a mantém em espera até maxAge. allow só faz sentido com job particionado por chave e sem estado compartilhado.
Agendamentos em 0 * * * * concentram carga no mesmo instante em todos os projetos da região e aumentam o tempo de concessão de lease. Distribua o minuto de início; a diferença medida entre 0 3 * * * e 15 3 * * * em regiões saturadas chega a 8 segundos de atraso mediano no disparo.
Bots
Bots com IA da Anthares podem tanto disparar automações quanto ser um passo dentro delas. No primeiro caso, o bot emite um evento no barramento com a mesma estrutura de envelope de qualquer outra origem — não há caminho privilegiado. No segundo, o passo bot/invoke chama o bot com um prompt montado a partir do estado da execução e grava a resposta no log como qualquer outro resultado de passo.
A diferença operacional é que a saída de um bot não é determinística. Por isso bot/invoke é sempre passo com resultado gravado: no replay, a resposta original vem do log e o modelo não é chamado de novo — o replay segue determinístico e a inferência não é cobrada duas vezes. O contraponto é que corrigir uma resposta ruim exige execução nova, não replay.
const triage = await step.run('classify-ticket', {
timeout: '45s',
retry: { max: 2, strategy: 'exponential-jitter' }
}, () => bots.invoke('bot_support_triage', {
input: { subject: input.subject, body: input.body },
schema: { severity: 'p1|p2|p3', team: 'string' },
maxOutputTokens: 400
}));
if (triage.severity === 'p1') {
await step.run('page-oncall', { timeout: '10s' },
() => alerts.page('squad-' + triage.team));
}
Bot como gatilho
Emite eventos tipados após uma conversa. Sujeito ao mesmo filtro, deduplicação e limite de taxa dos demais produtores.
Bot como passo
Resposta gravada no log e reutilizada no replay. Timeout máximo de 120 s e saída restrita ao schema declarado.
Integrações
Conectores encapsulam credencial, endpoint e política de retry de um destino. Os conectores internos — sql/execute para o Postgres gerenciado, storage/put, queue/publish, vpn/http — resolvem o endereço pela rede privada do projeto e não atravessam a internet pública. O conector vpn/http é o caminho para sistemas em datacenter próprio: ele roteia pela VPN privada e falha com E_TUNNEL_DOWN em vez de tentar rota alternativa, o que evita vazamento de tráfego pela rota padrão.
Conectores HTTP genéricos declaram quais códigos de status são retentáveis. O padrão é retentar 408, 429, 500, 502, 503 e 504, e nunca retentar os demais 4xx — um 400 ou 422 não muda de resultado por repetição e retentá-lo apenas atrasa a ida para a DLQ. Para 429, o conector honra o cabeçalho Retry-After quando presente, sobrepondo o cálculo de backoff próprio.
-- o índice único em (run_id, step_id) é o que torna o retry inofensivo
insert into ledger_entries (run_id, step_id, account_id, amount_cents)
values ($1, $2, $3, $4)
on conflict (run_id, step_id) do nothing
returning id;
- id: con_erp_private
type: vpn/http
baseUrl: https://erp.interno.local
network: vpn_pj_checkout
auth:
kind: oauth2-client-credentials
secretRef: sec_erp_client
retryOn: [408, 429, 500, 502, 503, 504]
timeout: 20s
circuitBreaker:
errorRateThreshold: 0.5 # abre com 50% de erro na janela
window: 60s
openFor: 30s
O circuit breaker é por conector e por projeto. Aberto, os passos falham imediatamente com E_CIRCUIT_OPEN sem consumir orçamento de retry. Depois de openFor, ele libera uma requisição de prova; se ela falhar, o ciclo reinicia com o dobro do tempo, até 5 minutos.
Confiabilidade
O retry padrão usa backoff exponencial com jitter completo. O atraso da tentativa n é sorteado uniformemente no intervalo [0, min(base * 2^(n-1), teto)], com base = 1s e teto = 300s. O jitter completo, em vez do jitter parcial, é escolha deliberada: com atraso fixo ou levemente perturbado, todas as execuções que falharam pelo mesmo motivo voltam juntas e reproduzem o pico que causou a falha. Com jitter completo, a distribuição de retorno é plana e a carga a jusante fica próxima da capacidade média.
O custo é maior variância na latência de recuperação de um caso individual: uma tentativa pode ser sorteada com 0,3 s e outra com 240 s no mesmo estágio. Para fluxos com prazo rígido, prefira reduzir o teto a 30 s e aumentar max, o que mantém a dispersão sob controle sem abrir mão do efeito anti-tempestade.
| Tentativa | Janela de sorteio | Atraso acumulado (pior caso) |
|---|---|---|
| 1 → 2 | 0 – 1 s | 1 s |
| 2 → 3 | 0 – 2 s | 3 s |
| 3 → 4 | 0 – 4 s | 7 s |
| 4 → 5 | 0 – 8 s | 15 s |
| 5 → 6 | 0 – 16 s | 31 s |
| n ≥ 10 | 0 – 300 s (teto) | cresce 300 s por tentativa |
Replay de execução
Replay reexecuta um run reaproveitando os resultados já gravados até um ponto de corte. É a ferramenta para recuperar execuções que falharam por bug corrigido depois: em vez de repetir passos caros e já bem-sucedidos, o executor os lê do log e retoma no primeiro passo que precisa mudar. O corte pode ser por step_id ou por instante.
Identificar o conjunto
a44 automation runs --state failed --step provision-db --since 48hlista os candidatos e imprime o total antes de qualquer ação.Escolher o ponto de corte
--from-stepinvalida aquele passo e todos os posteriores. Passos anteriores são lidos do log e não reexecutam.Rodar em modo de simulação
--dry-runimprime, por execução, quais passos seriam reaproveitados e quais reexecutariam, sem efeito externo.Executar com limite de taxa
Replay em massa respeita a concorrência da fila. Use
--ratepara ficar abaixo dela e preservar capacidade para o tráfego novo.
{
"matched": 412,
"fromStep": "provision-db",
"perRun": {
"reused": ["create-tenant"],
"replayed": ["provision-db", "cooldown", "notify-owner"]
},
"estimatedDurationSeconds": 1980,
"warnings": ["3 runs em compensation_failed foram ignorados"]
}
Se o passo posterior ao corte já havia rodado parcialmente antes da falha, o replay o executará de novo. Sem chave de idempotência no destino, isso duplica o efeito. Verifique reused e replayed no dry-run antes de confirmar.
Monitoramento
Toda execução emite um trace com um span por tentativa de passo, exportável por OTLP para o Monitoring da plataforma ou para um coletor próprio. Os atributos incluem a44.run_id, a44.step_id, a44.attempt, a44.queue e a44.partition_key, o que permite responder perguntas de causa raiz sem sair do trace — por exemplo, distinguir se a latência subiu porque um passo ficou lento ou porque a concessão de lease demorou.
As métricas mais úteis em alerta não são as de volume. Taxa de execuções por minuto sobe e desce com o negócio e gera ruído. Os sinais que costumam indicar problema real são idade da mensagem mais antiga na fila, profundidade da DLQ e taxa de reconcessão de lease — este último indica workers morrendo ou visibilityTimeout menor que a duração real dos passos.
queue_oldest_age_seconds
Idade do item mais antigo aguardando. Alerta em mais de 300 s sustentados por 5 min.
dlq_depth
Mensagens mortas por fila. Alerta em qualquer variação positiva sustentada por 10 min.
lease_reclaim_total
Leases reconcedidos por expiração. Taxa acima de 1% sinaliza timeout mal dimensionado.
a44 monitor alert create \
--name "dlq-invoice-settle" \
--metric automation.dlq_depth \
--filter 'queue="q_invoice_settle"' \
--condition "min_over_time(10m) > 0" \
--severity p2 --notify squad-billing
Os logs estruturados ficam 30 dias na consulta interativa e 13 meses no storage frio, em Parquet particionado por dia. A consulta aceita filtro por error_code, step_id e partition_key, com teto de 10.000 linhas por resposta.
Segurança
Segredos usados por conectores nunca aparecem na definição do workflow. A referência é por identificador (secretRef: sec_erp_client) e o valor é injetado no processo do worker no momento da execução, mantido apenas em memória e descartado ao final. Segredos não são gravados no log de passos; o serializador de resultados aplica redação automática em campos cujo nome corresponde a password, token, secret, authorization e api_key, substituindo o valor por [redacted].
A redação automática é rede de segurança, não garantia. Ela funciona por nome de campo e não detecta um token embutido no meio de uma string livre — por exemplo, uma URL com credencial no query string. Passos que lidam com material sensível devem retornar apenas o mínimo necessário para o passo seguinte, já que tudo o que for retornado ficará no log pelo período de retenção do projeto.
automation:read, automation:write, automation:replay) e expiração máxima de 90 dias.a44 auth token create \
--name ci-deploy-automation \
--scope automation:write \
--expires 30d \
--allow-cidr 203.0.113.0/24
automation:replay permite reexecutar efeitos externos em massa. Não o inclua em tokens de CI. Todo replay é registrado no log de auditoria com ator, filtro aplicado e contagem de execuções afetadas, retido por 400 dias.
Boas práticas
A regra que mais reduz incidente é dimensionar o visibilityTimeout pelo percentil 99 do passo mais lento, com folga de pelo menos 50%. Timeout curto demais causa reconcessão durante execução válida — o passo roda duas vezes e, se não for idempotente, duplica efeito. Timeout longo demais atrasa a recuperação quando o worker realmente morre. Medir antes de escolher sai mais barato que descobrir depois pelo alerta de lease_reclaim_total.
A segunda regra é manter passos pequenos, com uma responsabilidade externa cada. Um passo com três chamadas HTTP não tem ponto de retomada intermediário: falhar na terceira obriga a repetir as duas primeiras. Dividir em três custa três linhas no log, cerca de 1,2 KB, e devolve granularidade de retry e replay.
| Padrão a evitar | Consequência | Alternativa |
|---|---|---|
| Chave de idempotência com timestamp | Nenhuma deduplicação; execuções duplicadas a cada retry do produtor. | Derivar de recurso mais transição de estado. |
ordering: by-partition com chave de baixa cardinalidade | Vazão cai ao número de chaves distintas; a fila cresce sem limite. | Usar a chave mais específica que ainda preserve a ordem necessária. |
| Passo irreversível no início do fluxo | A compensação precisa desfazer efeito já visível para terceiros. | Mover validações para antes e efeitos externos para o fim. |
| Redrive completo da DLQ sem filtro | Reintroduz a carga que causou a falha e derruba o destino de novo. | Filtrar por error_code e limitar com --rate. |
| Retry em 4xx não retentáveis | Consome o orçamento de tentativas e atrasa a ida para a DLQ em minutos. | Restringir retryOn a 408, 429 e 5xx. |
Todo passo com timeout explícito; todo passo irreversível com compensate; chave de idempotência derivada do dado; maxAge definido no trigger; alerta de dlq_depth ativo; e um replay em dry-run exercitado ao menos uma vez em staging.
FAQ
As perguntas abaixo concentram as dúvidas dos primeiros meses de uso, quase todas sobre garantias de entrega e falha parcial. Onde há trade-off, ele está declarado em vez de resolvido: a escolha depende do custo de duplicar um efeito versus perder um evento no seu domínio.
Casos fora daqui — migração de agendador existente ou volumes acima de 5.000 execuções por segundo — exigem dimensionamento de fila e particionamento do log fora do padrão desta página.
Existe entrega exatamente-uma-vez de verdade?
Não no nível da rede. O que existe é entrega ao-menos-uma-vez combinada com efeito no-máximo-uma-vez, obtido por chave de idempotência e índice único no destino. Qualquer sistema que prometa exatamente-uma-vez fim-a-fim está descrevendo essa mesma combinação com outro nome.
Por que meu passo executou duas vezes se o retry está em zero?
retry.max: 0 desliga o retry por erro, mas não impede a reconcessão de lease. Se o worker perdeu o heartbeat por mais de visibilityTimeout, o agendador reconcede o passo a outro worker. Para bloquear isso, use exclusive: true — ao custo de a execução ficar travada até intervenção manual quando o worker realmente morre.
Qual a diferença prática entre overlap: skip e catchUp?
overlap trata a ocorrência que chegou enquanto a anterior ainda roda. catchUp trata ocorrências que não chegaram a ser disparadas, por indisponibilidade do agendador. São eixos independentes: é possível pular sobreposições e ainda assim recuperar ocorrências perdidas.
Posso alterar um workflow com execuções em andamento?
Sim, publicando uma nova versão. Execuções já iniciadas continuam na versão em que começaram até terminar — o executor fixa a versão no início do run. Isso evita divergência de replay, mas significa que uma correção urgente não afeta execuções presas em espera longa; nesses casos, cancele e redispare.
Como garantir ordem entre duas entidades diferentes?
Compartilhando a chave de partição. Se o pedido e o pagamento precisam ser processados em ordem, ambos devem usar partition_key derivada do mesmo identificador — normalmente o do cliente ou o do pedido. Não há ordem total entre partições distintas em nenhuma circunstância.
O que acontece se o Postgres do log ficar indisponível?
Novas execuções param de ser aceitas e a ingestão passa a responder 503 com Retry-After. Execuções em andamento não avançam além do passo atual, porque nenhum resultado pode ser confirmado. Nada é perdido: emissores que respeitam o 503 reenviam, e a chave de idempotência evita duplicação quando o serviço volta.
A DLQ consome cota de execuções?
Não. Mensagens paradas na DLQ não consomem cota de execução, apenas armazenamento, cobrado por GB-mês até o limite de retenção de 14 dias. O redrive, por outro lado, cria execuções novas e é contabilizado normalmente.
Dá para testar a verificação de assinatura localmente?
Sim. a44 automation webhook sign --trigger trg_x --file payload.json imprime os cabeçalhos A44-Signature e A44-Timestamp válidos para o segredo ativo, permitindo reproduzir a requisição com curl contra um servidor local.