Liberando os poderes do Laravel Horizon
Tags: horizon, laravel, queues, php
Published: 2023-09-08 15:40:03

Particularmente acho que jobs em background são uma mão na roda. Que seja para importação de um grande arquivo csv, processamento de dados que chegaram de um webhook, dentre tantas outras coisas que podemos designar para serem processadas sem que ninguém esteja olhando.

No Laravel não é diferente. O framework nos provê uma poderosa feature de processamento de filas totalmente out-of-the-box. Vamos recapitular como podemos executar a nossa fila:

```bash
php artisan queue:work {connectionName} --queue={queueName} ...
```

Acima, temos um exemplo bem básico de como iniciar a nossa fila. Existem várias outras opções e, para conhecer todas, sugiro dar uma conferida na [documentação](https://laravel.com/docs/10.x/queues) oficial do framework.

## A vinda do Horizon

O horizon foi lançado em 2017 e é um package para execução e monitoramento da fila (Lembre-se, o que não é medido, não pode ser gerenciado). Ele veio não com a missão de substituir, mas a de complementar as queues do Laravel.

Com ele, temos uma execução bem mais simples de tudo o que eu preciso para rodar a minha fila, além de definir mecanismos para balancear a execução de nossa fila.

## Um problema constante…

Um dos problemas que mais vi em aplicações em que dei manutenção foi a subutilização do horizon. Como assim Matheusão? Vejamos:

- Projetos que utilizavam somente um worker e com todas as filas configuradas nesse worker
- Gerenciamento de memória deficiente
- Número de workers incompatível com a quantidade que jobs que o sistema processa

> Quer dizer que eu não sei configurar a minha fila, Matheusão?

Não é isso. Cada projeto tem a sua particularidade, e todas as configurações que farei aqui vão depender do uso massivo ou não do sistema de filas.

Neste pequeno artigo, vou mostrar pra vocês como otimizar o seu horizon para que possa receber vários jobs e executar tudo de forma suave.

## A configuração padrão

Por padrão, o horizon vem com um worker configurado apenas:

```php
'defaults' => [
    'supervisor-1' => [
	'connection'          => 'redis',
        'queue'               => ['default'],
        'balance'             => 'auto',
        'autoScalingStrategy' => 'time',
        'maxProcesses'        => 3,
        'maxTime'             => 0,
        'maxJobs'             => 0,
        'memory'              => 128,
        'tries'               => 1,
        'timeout'             => 60,
        'nice'                => 0,
    ],
],
```

Maravilha, mas o que essa configuração mostra pra gente?

- Nosso worker trabalhará com a fila `default`
- Nesse caso, o balanceamento não se aplica, pois só temos uma fila
- Teremos, no máximo, 3 processos em execução
- Os jobs serão executados somente uma vez, não sendo permitidas outras tentativas
- Os jobs terão timeout de 60 segundos

Maravilha, amigos. Com esta configuração padrão, uma aplicação consegue rodar bastante coisa, mas, e se nossa demanda começar a crescer?

### Adicionando filas ao worker

Vamos imaginar que eu tenho uma fila para para processar e-mails e outra para os demais jobs do sistema. Top, poderia fazer da seguinte forma:

```php
'defaults' => [
    'supervisor-1' => [
	'connection'          => 'redis',
        'queue'               => ['emails', 'default'],
        'balance'             => 'auto',
        'autoScalingStrategy' => 'time',
        'maxProcesses'        => 3,
        'maxTime'             => 0,
        'maxJobs'             => 0,
        'memory'              => 128,
        'tries'               => 1,
        'timeout'             => 60,
        'nice'                => 0,
    ],
],
```

Massa, mas, o que temos agora?

- Duas filas sendo executadas pelo worker
- máximo de 3 processos para as filas

Com esta configuração, quem estiver precisando mais, leva mais processos com ele. Imagine você que a fila de e-mails tem 40 jobs e a fila default, só 4. O horizon vai disponibilizar 2 processos para a fila `emails` e 1 processo para a fila `default`.

## Configurando vários workers no horizon

Agora chegaremos ao ápice do nosso cenário. Vamos imaginar o seguinte:

- Notificações (via slack por exemplo)
- Envio de emails
- Recebimento de dados de webhooks
- Importação de dados via CSV

> Cara, não tem coisa demais ai não?

Vamos com calma que o negócio vai dar bom.

### Priorizando nossa fila

Pra começar os trabalhos, vamos categorizar as nossas filas

- Alta prioridade
- Baixa Prioridade
- Default
- Jobs que podem levar muito tempo para finalizarem

Maravilha, agora que já tenho as minhas categorias, vou fazer uso de um `enum` que guardará essas categorias pra mim:

```php
<?php

declare(strict_types=1);

namespace App\Enums;

enum QueuePriority: string
{
    case Low         = 'low';
    case High        = 'high';
    case LongTimeout = 'long-timeout';
}
```

Note que eu não adicionei a fila `default` nesse enum, pelo simples fato de que os jobs que não forem categorizados já vão direto pra ela. Mas se quiser colocar, fique à vontade 😃.

Dessa forma, podemos despachar os nossos jobs da seguinte forma:

```php
InviteUser::dispatch($user)->onQueue(
    QueuePriority::High->value
);
```

Elegante, não?

### Tunando o nosso horizon

Agora, poderemos criar mais workers e deixar nossa fila ainda mais separada e organizada:

```php
'defaults' => [
    'supervisor-high-priority' => [
        'connection'          => 'redis',
        'queue'               => [QueuePriority::High->value],
        'balance'             => 'auto',
        'minProcesses'        => 1,
        'maxProcesses'        => 6,
        'balanceMaxShift'     => 3,
        'balanceCooldown'     => 2,
        'autoScalingStrategy' => 'size',
        'maxTime'             => 0,
        'maxJobs'             => 0,
        'memory'              => 128,
        'tries'               => 1,
        'timeout'             => 60,
        'nice'                => 0,
    ],
    'supervisor-low-priority' => [
        'connection'          => 'redis',
        'queue'               => [QueuePriority::Low->value, 'default'],
        'balance'             => 'auto',
        'minProcesses'        => 1,
        'maxProcesses'        => 3,
        'balanceMaxShift'     => 1,
        'balanceCooldown'     => 3,
        'autoScalingStrategy' => 'size',
        'maxTime'             => 0,
        'maxJobs'             => 0,
        'memory'              => 128,
        'tries'               => 1,
        'timeout'             => 60,
        'nice'                => 0,
    ],
    'supervisor-long-timeout' => [
        'connection'          => 'redis',
        'queue'               => [QueuePriority::LongTimeout->value],
        'balance'             => 'auto',
        'minProcesses'        => 1,
        'maxProcesses'        => 3,
        'balanceMaxShift'     => 1,
        'balanceCooldown'     => 3,
        'autoScalingStrategy' => 'size',
        'maxTime'             => 0,
        'maxJobs'             => 0,
        'memory'              => 128,
        'tries'               => 1,
        'timeout'             => 600,
        'nice'                => 0,
    ],
],

'environments' => [
    'production' => [
        'supervisor-high-priority' => [],
        'supervisor-low-priority'  => [],
        'supervisor-long-timeout'  => [],
    ],

    'staging' => [
        'supervisor-high-priority' => [],
        'supervisor-low-priority'  => [],
        'supervisor-long-timeout'  => [],
    ],

    'local' => [
        'supervisor-high-priority' => [],
        'supervisor-low-priority'  => [],
        'supervisor-long-timeout'  => [],
    ],
],
```

O que temos aqui, meu querido? Agora temos 3 workers para diferentes filas, com as seguintes configurações:

- supervisor-high-priority
    - Toma conta da fila `high`
    - Máximo de 6 processos
    - Vai subir ou matar 3 processos (`maxShift`) a cada 2 segundos (`coolDown`)
    - Timeout de **1** minuto
- supervisor-low-priority
    - Toma conta das filas `low` e `default`
    - Máximo de 3 processos
    - Vai subir ou matar 1 processo (`maxShift`) a cada 3 segundos (`coolDown`)
    - Timeout de **1** minuto
- supervisor-long-timeout
    - Toma conta da fila `long-timeout`
    - Máximo de 3 processos
    - Vai subir ou matar 1 processo (`maxShift`) a cada 3 segundos (`coolDown`)
    - Timeout de **10** minutos
    
Uma configuração simples, mas que dá conta do recado 😃.

## Pra finalizar…

Agora a nossa fila está configurada e pronta para metralhar uma boa quantidade de jobs. Mas lembre-se que, antes de aumentar os workers, certifique-se de que seu servidor tenha recursos disponíveis para seus novos workers.

Da forma que apresentei para vocês, a fila em si não trará gargalos para seu servidor; o que pode vir a trazer problemas, caso use o Redis por exemplo, é ver a sua aplicação cair por falta de memória, mas aí já é papo para outra conversa.

Sempre comece do pouco, aumente um processo aqui, outro ali, um timeout acolá. Jamais coloque tudo que você achar o máximo que o seu servidor consegue processar. Aos poucos e com prudência, você consegue achar o equilíbrio perfeito entre o que é processado em background e o restante da aplicação.

Um abraço e até a próxima 😗🧀