Na minha saga para implementar um Actor System, cobri até agora o uso básico de Actor. Agora, vou abordar o supervisor.
Supervisor
Caso você não saiba, no Model de Atores, um supervisor é uma peça fundamental para a tolerância a falhas. O supervisor monitora os filhos (atores) e reage quando um deles exibe um comportamento inadequado.
Um Supervisor é um tipo especial de ator, focado apenas em monitorar e realizar alguma ação quando os filhos apresentam mau comportamento.
Mau comportamento
No Model de Atores, um mau comportamento ocorre quando um ator sofre uma exceção durante a execução de uma mensagem.
Quando um ator lança uma exceção durante o processamento de uma mensagem, temos duas estratégias principais:
- One For One (Um para Um): Reinicia apenas o filho que falhou.
- All for One (Todos para Um): Reinicia todos os filhos, caso um deles falhe.
E o que fazer quando o reinício não funciona e ele continua falhando? Você pode parar o filho ou escalar o erro.
Reinício (Restart)
Outra questão é o que "reiniciar" significa no Model de Atores. No Model de Atores e no .NET, isso significa matar/descartar (dispose) aquela instância do ator e sua mailbox, mas manter a IActorReference. Se o ator for um supervisor, vamos parar e matar todos os filhos, tornando a IActorReference desses filhos inválida.
Tipos de supervisor
Antes de mergulhar no código, deixe-me mostrar quais são os supervisores que irei implementar:
- Supervisor: Um supervisor preemptivo; não permite a adição de mais filhos após a inicialização.
- DynamicSupervisor: Um supervisor dinâmico; permite criar filhos após a inicialização, mas suporta apenas a estratégia One For One.
- PartitionSupervisor: Um supervisor baseado em partição que cria um pool fixo de workers idênticos e roteia as mensagens usando particionamento baseado em hash.
Implementação
Base (Foundation)
Antes de implementar os supervisores, precisamos de alguns blocos de construção: a estratégia de supervisão (Strategy), a ação de falha (FailureAction) e a política de reinício (RestartPolicy).
public enum Strategy
{
OneForOne,
AllForOne,
}
public enum FailureAction
{
Restart,
Stop,
Escalate,
Resume,
}
Além da estratégia, cada ator filho possui uma RestartPolicy que define se ele deve ser reiniciado:
public enum RestartPolicy
{
Permanent, // Sempre reinicia, independentemente do motivo do término
Transient, // Reinicia apenas se terminar de forma anormal
Temporary, // Nunca reinicia
}
Agora, vamos primeiro criar uma interface chamada ISupervisor:
/// <summary>
/// Representa um ator supervisor que gerencia atores filhos.
/// </summary>
public interface ISupervisor : IActor
{
/// <summary>
/// Obtém a coleção de referências de atores filhos gerenciados por este supervisor.
/// </summary>
IEnumerable<IActorReference> Children { get; }
}
Também precisamos de uma forma de descrever como um filho deve ser criado. Para isso, temos o IChildSpecification:
public interface IChildSpecification
{
Type ActorType { get; }
IMailbox Mailbox { get; set; }
RestartPolicy RestartPolicy { get; set; }
}
E a implementação padrão:
public record ChildSpecification : IChildSpecification
{
public ChildSpecification(Type actorType)
{
ActorType = actorType;
}
public Type ActorType { get; }
public IMailbox Mailbox { get; set; } = new ChannelMailbox();
public RestartPolicy RestartPolicy { get; set; } = RestartPolicy.Permanent;
}
Cada ator supervisionado é rastreadu através de uma classe de metadados Child, que armazena a instância do ator, seu processo, mailbox, referência, política de reinício e contadores:
public class Child(
IActor actor,
IMailbox mailbox,
ActorProcess process,
LocalActorReference reference,
RestartPolicy restartPolicy,
Type actorType)
{
public IActor Actor { get; set; } = actor;
public ActorProcess Process { get; set; } = process;
public IMailbox Mailbox { get; } = mailbox;
public LocalActorReference Reference { get; } = reference;
public RestartPolicy RestartPolicy { get; } = restartPolicy;
public Type ActorType { get; } = actorType;
public int RestartCount { get; set; } = 0;
public DateTimeOffset LastRestartTime { get; set; } = DateTimeOffset.MinValue;
public Dictionary<string, object> Metadata { get; } = [];
public bool IsSupervisor => Actor is ISupervisor;
}
Supervisor
O Supervisor base é uma classe abstrata que implementa ISupervisor. Ele é preemptivo — os filhos são definidos durante a inicialização e não podem ser adicionados posteriormente.
public abstract partial class Supervisor(IActorFactory actorFactory, ILogger logger)
: Actor,
ISupervisor,
IHandleActorMessage<AddActor>,
IHandleActorMessage<ActorFailed>,
IHandleActorMessage<ActorTerminated>,
IAsyncDisposable
{
Ele expõe propriedades virtuais para que as subclasses possam customizar o comportamento:
protected virtual Strategy Strategy => Strategy.OneForOne;
protected virtual int MaxRestarts => 3;
protected virtual TimeSpan RestartWindow => TimeSpan.FromSeconds(5);
Durante a inicialização, ele chama o método abstrato OnInitializeAsync, onde as subclasses definem seus filhos:
public sealed override async ValueTask InitializeAsync(
CancellationToken cancellationToken = default)
{
await OnInitializeAsync(cancellationToken);
_initialized = true;
}
protected abstract ValueTask OnInitializeAsync(
CancellationToken cancellationToken = default);
Quando um filho falha, o supervisor determina o que fazer através do GetFailureAction. Por padrão, ele reinicia o filho, a menos que o limite de reinícios seja excedido, caso em que ele escala a falha:
protected virtual FailureAction GetFailureAction(Child child, Exception exception)
{
if (child.RestartCount >= MaxRestarts)
{
return FailureAction.Escalate;
}
return FailureAction.Restart;
}
Quando a ação é Restart, a estratégia define quem será reiniciado:
protected virtual async Task ApplyRestartAsync(Child child)
{
child.RestartCount++;
child.LastRestartTime = DateTimeOffset.UtcNow;
if (Strategy == Strategy.OneForOne)
{
await ResetActorAsync(child);
}
else if (Strategy == Strategy.AllForOne)
{
await Task.WhenAll(Children.Select(ResetActorAsync));
}
}
DynamicSupervisor
O DynamicSupervisor estende o Supervisor para permitir a adição e remoção de filhos em tempo de execução. Ele é selado com a estratégia OneForOne, pois reiniciar todos os atores quando um falha não faz sentido quando os atores são criados de forma independente.
public abstract class DynamicSupervisor(IActorFactory actorFactory, ILogger logger)
: Supervisor(actorFactory, logger),
IHandleActorMessage<RemoveChild>
{
protected sealed override Strategy Strategy => Strategy.OneForOne;
RootSupervisor
O RootSupervisor é uma implementação concreta do Supervisor que serve como o supervisor de nível superior no sistema de atores. Ele implementa a interface de marcação IRootSupervisor e é configurado via RootSupervisorOptions.
Diferente do Supervisor base, o RootSupervisor sempre reinicia os filhos que falham — ele nunca escala, já que não existe um supervisor pai acima dele.
PartitionSupervisor
O PartitionSupervisor<TActor> cria um pool fixo de atores workers idênticos e roteia mensagens para eles usando particionamento baseado em hash. O número de workers é, por padrão, Environment.ProcessorCount.
As mensagens são roteadas para um worker usando uma chave de partição. A chave sofre o hash e é mapeada para um índice de worker:
protected virtual IActorReference GetActorReference<TKey>(TKey key)
where TKey : notnull
{
var hash = Math.Abs(GetHashcode(key));
return Children[hash % Children.Count].Reference;
}
Isso garante que mensagens com a mesma chave sempre vão para o mesmo worker, o que é útil para cenários como afinidade de sessão ou particionamento consistente de dados.
Conclusão
Com o Supervisor, DynamicSupervisor, PartitionSupervisor e o RootSupervisor como ponto de entrada de nível superior, agora temos uma base sólida para tolerância a falhas em nosso sistema de atores. Cada variante de supervisor aborda um caso de uso diferente.
Se você estiver interessado nos detalhes profundos da implementação, sinta-se à vontade para explorar o código-fonte completo no GitHub: lillo42/trupe.
Top comments (0)