/ API

Evitando execuções repetidas de tarefas agendadas ao escalar sua aplicação

Tarefas agendadas são muito utilizadas no desenvolvimento de software. A grande maioria das aplicações conta com essas rotinas que possuem as mais diversas finalidades. Neste artigo detalharemos um problema que pode passar despercebido ao escalar uma aplicação que possui tarefas agendadas e também uma solução simples para este problema.

Como mencionado anteriormente, tarefas agendadas estão por toda parte. Um exemplo muito comum é o fechamento das faturas de cartão de crédito. Todos os meses quando a data de fechamento da fatura é atingida, a empresa provedora do serviço de cartões de crédito realiza o congelamento dos lançamentos naquela fatura e envia um e-mail de notificação ao cliente com o boleto em anexo para pagamento. Conforme a data limite para pagamento se aproxima, algumas provedoras ainda contam com outras tarefas agendadas para enviar lembretes aos clientes que ainda não realizaram o pagamento.

Considerando o cenário onde a aplicação possui apenas uma instância do software rodando, as tarefas agendadas serão executadas por essa instância. Porém, quando consideramos uma instalação com múltiplas instâncias, ou seja, quando a mesma aplicação é replicada para atender uma grande demanda de utilização, encontramos um problema grave: todas as instâncias executarão a mesma rotina que foi agendada. Utilizando o nosso exemplo da provedora de cartões de crédito, se existir 5 instâncias ativas, os clientes receberão 5 e-mails de fechamento de fatura, caso este cenário não tenha sido tratado.

Visto que o escalonamento horizontal das aplicações tem se tornado cada vez mais comum - e automático na grande maioria dos casos, elaboramos este post com intuito de apresentar uma abordagem simples para evitar que tarefas agendadas sejam executadas repetidamente por sistemas que estejam instalados em ambientes preparados para escalar.

A biblioteca SchedLock, disponível em https://github.com/lukas-krecan/ShedLock, provê um mecanismo simples que assegura que tarefas agendadas sejam executadas apenas por apenas uma instância. Essa garantia se dá a partir da aquisição de um lock - que é salvo em banco de dados - que previne que outra instância ou thread execute a mesma tarefa.

Exemplo na prática

Considere o seguinte job que faz parte do serviço shedlockdemo:

@Component
public class NotificarMudancaMinutoJob {

    @Scheduled(cron = "0 * * * * *")
    public void run() {
        final SimpleDateFormat formatter = new SimpleDateFormat("dd/MM/yyyy HH:mm");
        System.out.println("[JOB] Olá, se passou mais um minuto. Hora atual " + formatter.format(new Date()));
    }
}

Ao executar o job em um ambiente dockerizado com apenas uma instância do serviço, recebemos a seguinte saída toda vez que a rotina é executada (neste caso, uma vez a cada minuto):

saida_console_1_instancia-1

Porém, ao realizarmos o escalonamento horizontal do serviço shedlockdemo através do comando docker-compose scale shedlockdemo=4 para termos 4 instâncias rodando simultâneamente, a nossa saída passa a ser a seguinte:

saida_console_4_instancias_sem_schedlock-1

Percebam, na imagem acima, que cada instância do serviço shedlockdemo executou a tarefa que foi agendada.

Em nosso singelo exemplo, nenhum efeito colateral seria causado pela tarefa agendada NotificarMudancaMinutoJob. No entanto, caso essa tarefa execute operações de salvamento de dados, envio de e-mails ou outras operações que sejam vitais para o negócio, o efeito colateral pode comprometer a operação do sistema.

Veja a seguir como corrigir este problema de forma simples com a biblioteca ShedLock.

Utilizando a biblioteca ShedLock

  1. Adicione a dependência no arquivo pom.xml:
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>4.21.0</version>
</dependency>
  1. Habilite o lock de tarefas agendadas junto ao Spring:
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
class SchedlockdemoApplication {
    ...
}
  1. Anote os métodos que serão controlados com a anotação @SchedulerLock:
@Component
public class NotificarMudancaMinutoJob {

    @Scheduled(cron = "0 * * * * *")
    @SchedulerLock(name = "NotificarMudancaMinutoJob.run", lockAtLeastFor="30s")
    public void run() {
        final SimpleDateFormat formatter = new SimpleDateFormat("dd/MM/yyyy HH:mm");
        System.out.println("[JOB] Olá, se passou mais um minuto. Hora atual " + formatter.format(new Date()));
    }
}
  • O atributo name é obrigatório e deve ser único, sendo que o autor aconselha atribuir NomeClasse.nomeMetodo
  • O atributo lockAtLeastFor é opcional e define o tempo mínimo para o lock. Como nosso job roda no início de cada minuto, iremos realizar o lock de 30s para não comprometer a próxima execução.

O autor deixa claro que a lib não sincroniza as execuções, se um nó estiver executando a tarefa, os outros nós não ficarão esperando para executar. Ao invés disso, eles não executarão a tarefa (skip).

Para consultar a documentação completa e outras opções de configuração, visite a página do github da lib https://github.com/lukas-krecan/ShedLock.

  1. Crie uma tabela no banco de dados para armazenar os registros de lock. No nosso caso, estamos utilizando o banco de dados relacional PostgreSQL. No github da lib existe scripts de criação para todos os sgdb's suportados.
CREATE TABLE shedlock(
    name VARCHAR(64) NOT NULL, 
    lock_until TIMESTAMP NOT NULL,
    locked_at TIMESTAMP NOT NULL, 
    locked_by VARCHAR(255) NOT NULL, 
    PRIMARY KEY (name)
);
  1. Por fim, configure o LockProvider para o Spring:

Adicione a dependência do LockProvider para JDBC no pom.xml

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>4.21.0</version>
</dependency>

Realize a configuração:

import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;

...
@Bean
public LockProvider lockProvider(DataSource dataSource) {
    return new JdbcTemplateLockProvider(
        JdbcTemplateLockProvider.Configuration.builder()
        .withJdbcTemplate(new JdbcTemplate(dataSource))
        .usingDbTime()
        .build()
    );
}

Testando a implementação

Após realizar as modificações para incluir o mecanismo SchedulerLock e executar novamente a aplicação com 4 instâncias, temos a seguinte saída:

saida_console_4_instancias_com_shedlock-1

Note que nem sempre é a mesma instância que executa a tarefa, porém a tarefa é executada somente uma vez por ciclo.

A imagem seguinte ilustra como o lock é salvo no banco de dados. Podemos notar o efeito do atributo lockAtLeastFor analisando o valor das colunas locked_at e locked_until.

tabela_lock

Conclusão

Neste artigo abordamos uma solução simples para evitar problemas de execução de tarefas agendadas por meio de um mecanismo de lock. O código utilizado para este artigo está disponível em https://github.com/hengling/shedlockdemo. A branch master está no estado
inicial e a branch solucao_completa contém o projeto pronto. Que tal testar este mecanismo você mesmo?

Espero que este artigo tenha sido útil e aproveito para reforçar a sugestão de conferirem o repo oficial da lib em https://github.com/lukas-krecan/ShedLock.