ADR-0028 Jobs em segundo plano e agendamento
Padrão da empresa para trabalho fora do pedido — filas e job schedulers do BullMQ sobre o Valkey que já corremos, processados por um contentor worker da mesma imagem.
- Decisão
- BullMQ sobre ValkeyConstruir a app
- Estado
- Aceite
- Data
- Âmbito
- Predefinição da empresa para todas as novas apps
Ligações
Apoia-se em
Contexto
Seção intitulada “Contexto”Há trabalho que não deve correr dentro de um pedido: enviar email, chamar um terceiro lento, gerar uma exportação, tudo o que deve ser repetido quando falha. Outro trabalho corre a horas certas: limpezas, resumos, sincronizações.
- A app é um processo Bun em Docker (ADR-0003), substituído em cada deploy e por vezes a correr em várias réplicas. Um temporizador dentro dela dispara uma vez por réplica e morre com o contentor
- O Valkey já corre ao lado de cada app para os limites de pedidos e as sessões, e o ADR-0010 já contava com filas sobre ele
- O Postgres é a fonte de verdade (ADR-0007), e o ADR-0010 afastou dele, de propósito, as escritas de alta rotação
O que está em aberto: a biblioteca de filas, onde vive o seu estado e onde correm os jobs.
Opções consideradas
Seção intitulada “Opções consideradas”1. BullMQ sobre Valkey
Seção intitulada “1. BullMQ sobre Valkey”Prós
- Filas, repetições com backoff, jobs adiados, concorrência e Job Schedulers ao estilo cron numa só biblioteca MIT
- A documentação indica o Valkey como servidor suportado
- Corre sobre
ioredis, o cliente que já usamos (peer dependency desde o BullMQ 6.0) - O
bullmq-otelliga a colocação em fila e o processamento de um job num só trace (ADR-0015)
Contras
- Precisa de um Valkey que persista e nunca remova chaves — o ADR-0010 corria-o sem persistência
- A entrega é pelo menos uma vez: todos os handlers têm de poder correr duas vezes sem dano
- Colocar um job em fila não faz parte de uma transação Postgres
2. Uma fila sobre Postgres (pg-boss, ou o backend PostgreSQL do BullMQ)
Seção intitulada “2. Uma fila sobre Postgres (pg-boss, ou o backend PostgreSQL do BullMQ)”Prós
- Um job pode ser colocado em fila na mesma transação da escrita de negócio
- Nenhuma alteração ao Valkey; os jobs ficam cobertos pelo backup da base de dados
Contras
- Põe a rotação das filas no Postgres — a carga que o ADR-0010 manteve de fora
- O backend Redis do BullMQ é o predefinido; o PostgreSQL é o caminho mais recente
3. Temporizadores no processo web (setInterval, node-cron)
Seção intitulada “3. Temporizadores no processo web (setInterval, node-cron)”Prós: nada para operar. Contras: dispara uma vez por réplica; perde-se num reinício ou deploy; sem repetições, sem backoff, sem registo do que falhou.
Decisão
Seção intitulada “Decisão”BullMQ sobre Valkey é o padrão da empresa para jobs em segundo plano e trabalho agendado.
Em concreto:
- Pacotes:
bullmq6.x (6.7.6 à data de escrita) comioredis5.x instalado explicitamente — a mesma major a que o ADR-0009 nos obriga - Valkey: a instância do ADR-0010, agora com persistência AOF (fsync a cada segundo) e
maxmemory-policy noeviction. O guia de produção do BullMQ diz quenoevictioné a única definição que garante filas corretas - Duas ligações, nenhuma delas a do limitador. As filas no processo web ligam-se com
enableOfflineQueue: false, para que colocar um job em fila falhe depressa quando o Valkey está em baixo, em vez de prender o pedido. Os workers ligam-se commaxRetriesPerRequest: null, que o BullMQ exige — no cliente do limitador, essa definição faria as verificações de limite esperar em vez de recorrer aoinsuranceLimiter - Os jobs correm num contentor worker, nunca no processo web: um segundo entry point da
apps/webna mesma imagem, tal como o entry point de migrações do ADR-0016. O processo web só coloca em fila. Só passa a ser umaapps/worker/própria (ADR-0026) quando precisar das suas próprias dependências ou imagem - Um módulo por fila, em
server/jobs/: o nome, um schema Zod para o payload (ADR-0005) e o handler. O payload é validado ao colocar em fila e outra vez no worker — durante um deploy pode ter sido escrito por uma versão antiga. Os payloads levam IDs, não registos inteiros - Os handlers são idempotentes. Um job parado ou repetido volta a correr
- Todas as filas definem
attemptscombackoffexponencial, eremoveOnComplete/removeOnFailcom um número ou uma idade — um histórico de jobs sem limite enche um store que não pode remover chaves - Agendar é
queue.upsertJobScheduler(id, { pattern }), chamado quando o worker arranca. É um upsert, pelo que réplicas e novos deploys não duplicam um agendamento. Nada denode-cronnem desetIntervalpara trabalho recorrente - Colocar em fila depois do commit no Postgres, e desenhar o job de modo a que perdê-lo ou repeti-lo seja seguro
- Encerramento: em
SIGTERMo worker chamaworker.close()e sai dentro do período de tolerância; um job que não termine fica marcado como parado e é retomado - Telemetria:
bullmq-otel2.x passado comotelemetryàs filas e aos workers, comenableMetricsligado, para que os traces dos jobs e as métricas das filas saiam pelo endpoint OTLP da app (ADR-0015) - Testes: os testes de integração correm workers reais contra o contentor Valkey do ADR-0014
Os fatores decisivos: nenhuma infraestrutura nova, uma só biblioteca para filas e agendamentos, o cliente Redis que já usamos e a carga das filas fora do Postgres. Aceitamos ligar a persistência no Valkey e escrever handlers idempotentes.
Consequências
Seção intitulada “Consequências”Mais fácil:
- O trabalho lento e repetível sai do caminho do pedido; um envio falhado é repetido em vez de perdido
- Os agendamentos vivem no código, são entregues com a app e correm uma só vez, haja as réplicas que houver
- As ligações de verificação pendentes e o estado do OAuth (ADR-0009) passam a sobreviver a um reinício do Valkey
Mais difícil:
- O Valkey precisa de um volume e de um limite de memória com folga. Filas, sessões e limites de pedidos partilham-no: uma fila que encha a memória faz falhar as escritas das sessões e do limitador
- O Nodus tem de correr o entry point do worker como segundo contentor — ainda não faz parte do contrato do ADR-0016
- O AOF protege de um reinício, não da perda da máquina; se o conteúdo das filas precisa de backup decide-se por deployment (ADR-0032)
- A idempotência é um hábito, garantido na revisão
Do que abdicamos: de colocar um job em fila na mesma transação de uma escrita na base de dados. Aceite — um só store para filas, contadores e sessões compensa desenhar jobs que se podem perder ou repetir sem dano.

