Pular para o conteúdo

Especificação §8

Resiliência: supervisores, links e monitors

language-design.md §8 · 212 linhas · 10 min de leitura

@supervisor(strategy: one_for_one)
spawn {
    spawn ingest(src) catch |e| {
        log("ingest died: {{e}}")
    }
    spawn flush(out)
    spawn stats(tick)
}
O jeito como os spawns se aninham é a árvore. Um processo que cai morre sozinho, e o catch decide o que vem depois.

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.

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:

  1. Rodam os defers internos do processo (LIFO, seção 14): o cleanup determinístico que o processo registrou (fechar arquivo, soltar lock, @unpin de 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.
  2. Libera-se o @mm do processo: a memória dele volta ao seu MemoryManager (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.
  3. Produz-se o error que 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? o error carrega o motivo (qual variante, qual mensagem). Saiu limpo (terminou o trabalho)? a “morte” é uma saída normal. Esse error é 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”.

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.

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 |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.)

Quando um processo falha:

  1. Tentativa de recuperação: o processo tenta restaurar seu último estado válido
  2. Fresh start: se a recuperação falhar (corrupção, etc.), o processo reinicia limpo do ponto de entrada

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.

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}}")
}

Ú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| { ... }

@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.

O restart vem em duas formas, e qual delas você recebe depende de ter escrito um handler ou não:

  • @supervisor(...) num spawn SEM catch — o runtime reinicia sozinho, seguindo a estratégia e limitado por max_restarts. É a forma automática: você declarou a política e o runtime aplica.
  • @supervisor(...) num spawn COM catch — 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.

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.

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.