Ir para o conteúdo
Trilha
Capítulos

Receitas

Tarefas agendadas

Um ticker que sobe com o app e para com ele, um tique por vez, um pânico que não derruba o processo — e a linha em que o cron passa a ser a resposta certa.

Algum trabalho não tem requisição por trás: expirar sessões, mandar um resumo, tentar de novo o que falhou. Enquanto há uma instância, uma goroutine com um ticker é a resposta inteira, e ela mora dentro do app, com o mesmo pool e o mesmo logger.

O formato#

// Job is something that runs on a schedule inside the process. It is the
// right shape while one instance runs it; the moment there are two, the
// answer is a queue or a lock in the database, not a second ticker.
type Job struct {
	Name  string
	Every time.Duration
	Run   func(context.Context) error
}
// Start runs the job until the context is cancelled. A tick that arrives
// while the previous run is still going is dropped, not queued: a job that
// takes longer than its interval must not pile up copies of itself.
func Start(ctx context.Context, log *slog.Logger, j Job) {
	t := time.NewTicker(j.Every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			runJob(ctx, log, j)
		}
	}
}

Um time.Ticker descarta um tique quando ninguém está recebendo, e é esse o comportamento que você quer: uma tarefa que demora mais que o intervalo não pode acumular cópias de si mesma. O laço chama a tarefa e só depois volta a esperar.

// runJob keeps one tick from taking the process down. A panic in a
// background task is a bug, and a bug in one task should not stop the ones
// that work.
func runJob(ctx context.Context, log *slog.Logger, j Job) {
	defer func() {
		if r := recover(); r != nil {
			log.Error("job panicked", "job", j.Name, "panic", r)
		}
	}()
	start := time.Now()
	if err := j.Run(ctx); err != nil {
		log.Error("job failed", "job", j.Name, "error", err, "took", time.Since(start))
		return
	}
	log.Info("job done", "job", j.Name, "took", time.Since(start))
}

O recover não é otimismo sobre o seu código. Um pânico numa goroutine derruba o processo inteiro — servidor HTTP incluído — então um bug solto no resumo noturno pararia o site. Registrado e pulado, ele custa uma execução.

Subindo e parando#

// SetupJobs starts the tasks and makes the shutdown wait for them. A job
// killed halfway is a row written and an e-mail not sent, and it is the
// hardest kind of bug to reproduce.
func SetupJobs(a *trilha.App) error {
	ctx, stop := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	for _, j := range []Job{
		{Name: "expire-sessions", Every: time.Hour, Run: expireSessions},
	} {
		wg.Add(1)
		go func() {
			defer wg.Done()
			Start(ctx, a.Logger(), j)
		}()
	}
	a.OnShutdown(func(*trilha.App) error {
		stop()
		wg.Wait()
		return nil
	})
	return nil
}

O contexto é cancelado no desligamento e o WaitGroup faz o processo esperar o tique em andamento. Sem ele, um deploy mata uma tarefa no meio: a linha foi escrita, o e-mail não foi, e esse é o tipo de bug mais difícil de reproduzir.

A tarefa em si é uma função comum que recebe um contexto, então um teste chama ela direto, sem temporizador nenhum no meio:

// expireSessions is an ordinary function taking a context: nothing about it
// knows it runs on a timer, so a test calls it directly.
func expireSessions(ctx context.Context) error {
	_, err := DB.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < now()`)
	return err
}

Quando "uma instância" deixa de ser verdade#

No momento em que há duas instâncias, as duas ticam. Toda tarefa roda duas vezes, e "mandar o resumo" vira "mandar o resumo duas vezes".

SituaçãoO que fazer
uma instânciaesta receita
mais de uma, tarefa idempotentemantenha; rodar duas vezes não muda nada
mais de uma, tarefa que só pode rodar uma vezum lock no banco
trabalho que precisa sobreviver a um restartuma fila, não um ticker

O lock é menor do que parece — uma linha por nome de tarefa, um update condicional que só dá certo para a instância que chegar primeiro:

UPDATE job_locks SET locked_until = now() + interval '5 minutes', owner = $1
WHERE name = $2 AND locked_until < now();

Se o update não tocou nenhuma linha, outra instância está rodando, e esta pula o tique.