Resiliência: supervisores, links e monitors
@supervisor(strategy: one_for_one)
spawn {
spawn ingest(src) catch |e| {
log("ingest died: {{e}}")
}
spawn flush(out)
spawn stats(tick)
}A tua linguagem herda o modelo de resiliência da BEAM (Erlang/Elixir): processos isolados, sem memória compartilhada, que falham cedo e em silêncio (let it crash) em vez de espalhar estado corrompido, mais uma árvore de supervisão que reinicia o que caiu. Quem vem da BEAM conhece três conceitos centrais (links, monitors e supervisores), e esta seção mostra exatamente como cada um se expressa aqui. O resumo, pra ancorar: supervisores e a árvore emergem da estrutura do código (não de behaviours declarados à parte), links não são expostos crus (só pela árvore de supervisão), e monitors não são um construto novo, são o catch do spawn supervisionado. O resto da seção detalha cada um.
A morte de um processo
Seção intitulada “A morte de um processo”Antes de links e monitors, é preciso ser exato sobre o que acontece quando um processo morre, porque links e monitors são só reações a esse evento. A sequência é fixa e ordenada:
- Rodam os
defers internos do processo (LIFO, seção 14): o cleanup determinístico que o processo registrou (fechar arquivo, soltar lock,@unpinde um objeto dado ao C). É a última chance do processo de arrumar a casa, e roda dentro do processo moribundo, enquanto a pilha ainda existe. - Libera-se o
@mmdo processo: a memória dele volta ao seuMemoryManager(seção 5). Como cada processo tem seu próprio contexto de memória (isolamento), a morte limpa tudo de uma vez. Não há vazamento entre processos, e a arena/GC do morto simplesmente deixa de existir. - Produz-se o
errorque descreve a morte, e este é o ponto que conecta tudo: a morte de um processo é um valor de erro (error-as-value, seção 7). Crashou? oerrorcarrega o motivo (qual variante, qual mensagem). Saiu limpo (terminou o trabalho)? a “morte” é uma saída normal. Esseerroré o que será entregue a quem observa a morte.
Os três nessa ordem (cleanup, memória, notificação) garantem que, quando alguém é avisado da morte (passo 3), o processo já se desfez por completo (passos 1 e 2). Você nunca observa um processo “meio morto”.
Links: por que só via supervisor
Seção intitulada “Links: por que só via supervisor”Na BEAM, um link é uma ligação simétrica que propaga a morte: se A e B estão linkados e um morre, o outro recebe um sinal de saída e, por padrão, morre junto. É bidirecional (a ligação é mútua, não “quem linka quem”) e letal por default. É o mecanismo bruto que faz grupos de processos viverem e morrerem como unidade.
A tua linguagem não expõe links crus, não há link(pid). A razão é a que guiou o resto: cascata de morte é difícil de raciocinar (A mata B que mata C linkado a D… quem sobra?), e um link solto é um efeito não-local que não aparece na estrutura. Em vez disso, a única forma de acoplar destinos é a árvore de supervisão: processos no mesmo spawn-block (ou sob o mesmo supervisor) compartilham destino porque a estrutura do código diz isso, não porque alguém chamou link num lugar distante.
E o supervisor é um processo linkado, só que especial: na BEAM, um supervisor trapeia o sinal de saída (trap_exit) em vez de morrer com ele, e decide o que fazer. Aqui é exatamente o que o @supervisor faz: está acoplado aos filhos (recebe a morte deles), mas apara essa morte, que vira o error do catch em vez de derrubar o supervisor. Links existem, embaixo; você só os toca pela disciplina da árvore.
Monitors: o catch do spawn supervisionado
Seção intitulada “Monitors: o catch do spawn supervisionado”Na BEAM, um monitor é o oposto assimétrico do link: A monitora B, e se B morre, A recebe uma mensagem avisando, mas A não morre. É unidirecional (B nem sabe) e não-letal (a morte de B vira dado pra A, não sentença). É o que você usa pra saber que algo caiu sem cair junto.
Aqui está a unificação central da seção: um monitor não é construto novo, é o catch |e| do spawn supervisionado.
@supervisor(configs)spawn process(values) catch |e| { // 'process' morreu, e EU (o supervisor) não morri junto. // 'e' é o motivo da morte. Eu reajo, sem propagar.}Compara com a definição de monitor (“ser notificado da morte de outro processo, sem morrer junto, recebendo o motivo”) e é exatamente esse catch: o filho morre, o supervisor não morre e recebe o error no |e|. “Reportar a morte sem propagá-la” é, palavra por palavra, “tratar o erro sem re-lançá-lo”. O catch que você já usa pra erros comuns é o canal de monitoração: a morte chega pelo mesmo mecanismo que qualquer erro, porque a morte de um processo é um erro. (Reconcilia com a seção 7: aqui o catch está em posição de statement, não num value-binding. Não há v pra preencher, então o handler reage e segue, sem a obrigação de divergir que o trailer carrega quando liga um valor.)
É isso que torna links/monitors “já resolvidos” aqui: a BEAM precisa de dois conceitos separados (link acopla destino, monitor observa sem acoplar) porque lá ambos são primitivas do runtime. Aqui, acoplar destino é a árvore e observar sem acoplar é o catch, e os dois já existem por outras razões.
(Honestidade sobre o escopo: isso amarra a observação à relação de spawn, ou seja, você observa a morte dos filhos que você spawnou. É um estreitamento deliberado frente ao monitor da BEAM, que observa qualquer pid. Observar um processo que outro spawnou se faz pela via normal de mensagens, em que um processo te manda num channel, não por um monitor sobre pid arbitrário.)
O que a notificação carrega
Seção intitulada “O que a notificação carrega”O |e| é o motivo da morte, o mesmo error do passo 3. E como é um error comum, você o discrimina com o match |e| que já existe (seção 7), distinguindo como o processo morreu:
@supervisor(configs)spawn process(values) match |e| { Normal => log("process terminou seu trabalho") // saída limpa Crashed(msg) => restart() // crash com motivo Timeout => escalate() // travou OutOfMemory => alert_ops() // recurso esgotado}É match |e| direto no spawn, não catch |e| com um match dentro: o match |e| já captura e discrimina de uma vez (seção 7), e os dois juntos seriam o duplo-binding redundante. Morte limpa (terminou o trabalho) e morte por crash chegam pelo mesmo |e|, discriminadas pelas variantes, e você reinicia, escala, loga ou ignora conforme o motivo. Não há um canal pra “terminou normal” e outro pra “crashou”: um error só, com variantes, casado pelo match de sempre. (As variantes Normal, Crashed e afins são ilustrativas; o ponto é que o motivo é um valor estruturado, não um código opaco.)
Hierarquia de recuperação
Seção intitulada “Hierarquia de recuperação”Quando um processo falha:
- Tentativa de recuperação: o processo tenta restaurar seu último estado válido
- Fresh start: se a recuperação falhar (corrupção, etc.), o processo reinicia limpo do ponto de entrada
Modelo de supervisão
Seção intitulada “Modelo de supervisão”A árvore de supervisão emerge da estrutura do código, não de módulos ou behaviours declarados separadamente. As três estratégias do Erlang/OTP mapeiam naturalmente para a sintaxe:
one_for_one: processos independentes com catch individual
Seção intitulada “one_for_one: processos independentes com catch individual”Cada processo tem seu próprio handler. Se B morre, A não sabe e não se importa.
spawn worker_a(data) catch |e| { // A falhou: restart? log? drop? decisão do caller}spawn worker_b(data) catch |e| { ... }Sem nenhuma anotação, o comportamento padrão é one_for_one sem limite de restarts.
one_for_all: grupo coordenado com spawn-block
Seção intitulada “one_for_all: grupo coordenado com spawn-block”O bloco comunica que os processos vivem e morrem juntos. Se um membro falha, o coordinator mata os demais proativamente antes de reiniciar o grupo inteiro.
spawn { worker_a(data_a) catch |e| { // Camada 1: A falhou especificamente: notificação local // B e C não passam por aqui quando são terminados pelo coordinator log("worker_a down: {{e}}") } worker_b(data_b) catch |e| { ... } worker_c(data_c) catch |e| { ... }} catch |e| { // Camada 2: o grupo inteiro se esgotou (max_restarts excedido) // Aqui você decide: escalar? alertar? drop? notify_ops("grupo crítico morreu: {{e}}")}rest_for_one: pipeline com dependência de ordem
Seção intitulada “rest_for_one: pipeline com dependência de ordem”Único caso que exige anotação explícita, porque a semântica de “reiniciar os que vieram depois” não é inferível da estrutura:
@supervisor(strategy: rest_for_one)spawn { db_connection() // morre → reinicia os três db_writer() // morre → reinicia writer + reader db_reader() // morre → reinicia só reader} catch |e| { ... }Limites de restart
Seção intitulada “Limites de restart”@supervisor é opcional e só aparece quando você quer mudar algo do default:
// Com limite de tentativas@supervisor(max_restarts: 3, window: 10s)spawn worker(data) catch |e| { ... }
// Combinado com rest_for_one@supervisor(strategy: rest_for_one, max_restarts: 5, window: 30s)spawn { stage_a(data) stage_b(data)} catch |e| { ... }Quando max_restarts é excedido, o catch externo do bloco (ou do spawn individual) dispara com o erro acumulado. A partir daí, o caller decide; não há escalação automática implícita.
Quem reinicia: o runtime, ou você
Seção intitulada “Quem reinicia: o runtime, ou você”O restart vem em duas formas, e qual delas você recebe depende de ter escrito um handler ou não:
@supervisor(...)numspawnSEMcatch— o runtime reinicia sozinho, seguindo a estratégia e limitado pormax_restarts. É a forma automática: você declarou a política e o runtime aplica.@supervisor(...)numspawnCOMcatch— você assumiu o controle. O runtime faz o que o handler mandar:restart()força o restart agora,escalate()empurra a falha pra cima, e não fazer nenhum dos dois deixa o filho morto.
É por isso que o mesmo catch |e| é o monitor e o supervisor: a diferença é só se o handler age. E é por isso que uma saída limpa não é reiniciada por acidente — um handler que casa Normal e apenas loga decidiu, e o runtime não questiona.
runtime.supervisor.{restart, escalate}
@supervisor(max_restarts: 3, window: 10s)spawn process(values) catch |e| { match |e| { Normal => log("o process terminou seu trabalho") // decidiu: fica morto Crashed(msg) => restart() // força o restart Timeout => escalate() // entrega pra cima OutOfMemory => alert_ops() // decidiu: fica morto, mas grita }}restart() não recebe argumento: o runtime guardou a função e os argumentos do spawn, e o re-invoca a partir do estado inicial. O erro já está ligado pelo |e| do handler em que você está, então passá-lo seria se repetir. O processo reiniciado é novo por dentro, mas um nome registrado sobrevive (seção 3).
Como o restart re-invoca com os mesmos argumentos, um canal entregue ao processo como argumento de spawn reconecta: ele volta vivo, a ponta migra para o processo reiniciado (as contagens nunca caem), e um peer que segura a outra ponta — o pai que o spawnou, que nunca morreu — continua conversando com ele através do restart sem nunca ver um ProcessDown espúrio. O que não reconecta é um handle cru que outro processo guardou para este: esse handle nomeava a identidade interna morta, então fica stale e um send nele dá ProcessDown. Alcançar um processo reiniciável de fora é, portanto, pelo seu nome registrado (seção 3), nunca por um handle guardado. É exatamente o modelo do BEAM — mesmos argumentos do child-spec, um id interno novo, um nome que sobrevive.
escalate() também não recebe argumento. Mata o processo atual com o erro observado, entregando-o a quem supervisiona ele — é o supervisor dizendo “não é minha para tratar”. A sequência de morte comum roda (defers, @mm, erro). Sem ninguém acima, é uma falha não-supervisionada: se esse processo for o main, o programa sai com o erro. A escalação é sempre explícita, que é o outro lado do “não há escalação automática implícita” acima.
As duas moram em runtime.supervisor, não como globais nuas: nada é fundamental demais pra importar (seção 1). Ficam separadas da introspecção só-leitura de runtime (seção 19) porque só significam algo dentro do handler de um spawn supervisionado — restart() sozinho não tem o que reiniciar.
Árvore de supervisão por nesting
Seção intitulada “Árvore de supervisão por nesting”A árvore emerge naturalmente do aninhamento dos processos. Não há conceito separado de “supervisor process”: qualquer processo que spawna filhos é implicitamente seu supervisor:
// Root: one_for_one implícito (o programa principal)spawn http_server(cfg) catch |e| { restart() }
@supervisor(strategy: rest_for_one)spawn { // Sub-grupo: rest_for_one db_connection() db_writer() db_reader()} catch |e| { notify_ops(e) }O retry é explícito e definido pelo caller, não pelo callee. A função executada pelo processo é limpa, sem política de retry embutida:
@supervisor(max_restarts: 3, window: 10s)spawn worker(data) catch |e| { log("worker falhou após 3 tentativas: {{e}}")}A razão: se o callee definir seus próprios retries, ele se torna difícil de reutilizar e o caller perde controle em cenários críticos.
Do Erlang/OTP para cá: tabela de tradução
Seção intitulada “Do Erlang/OTP para cá: tabela de tradução”Pra quem vem da BEAM, o mapeamento direto:
| Erlang/OTP | Na tua linguagem |
|---|---|
spawn_link (acoplar destino) |
processos no mesmo spawn-block ou sob o mesmo @supervisor; a árvore acopla, não uma chamada |
monitor/2 (observar sem acoplar) |
o catch |e| do spawn supervisionado |
{'DOWN', ref, process, pid, reason} |
o |e| do catch, ou seja, o error que descreve a morte |
trap_exit (supervisor apara o sinal) |
o @supervisor aparando a morte do filho |
exit(reason) / motivo de saída |
o error produzido na morte (passo 3) |
Supervisor behaviour + child spec |
@supervisor(configs) sobre o spawn-block |
one_for_one / one_for_all / rest_for_one |
spawn individual / spawn-block / @supervisor(strategy: rest_for_one) |
restart intensity (max_restarts, period) |
@supervisor(max_restarts: N, window: T) |
A diferença filosófica em resumo: a BEAM dá primitivas de runtime (link, monitor, trap_exit) que você compõe pra construir supervisão; a tua linguagem faz a supervisão emergir da estrutura e trata a morte como error-as-value, então os conceitos da BEAM viram consequências de coisas que já existem (a árvore, o catch) em vez de primitivas separadas.