---
title: "O que `dbos ontime` realmente está perguntando: construindo um cron distribuído com leases do etcd em Go"
url: https://tiarebalbi.com/pt-br/blog/distributed-cron-etcd-lease-go
markdown: https://tiarebalbi.com/pt-br/blog/distributed-cron-etcd-lease-go.md
description: "Um lease do etcd com leader election e 100 linhas de Go cobrem cron exactly-once — com a matemática de failover e o fence que protege as escritas."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-05-07
updated: 2026-05-07
category: "Engineering"
tags: ["etcd", "go", "sistemas-distribuidos", "leader-election", "scheduler", "fencing-token", "cron", "exactly-once"]
translation: https://tiarebalbi.com/en/blog/distributed-cron-etcd-lease-go
---
# O que `dbos ontime` realmente está perguntando: construindo um cron distribuído com leases do etcd em Go

Uma busca 0-click apareceu no meu Search Console na semana passada: `dbos ontime`. Quatro impressões, nenhum post de destino. A query é interessante porque ela não é realmente sobre DBOS. Quem digita isso tem um job que precisa rodar a cada N minutos, exatamente uma vez, em uma frota — e está orçando produtos de durable-execution para chegar lá.

Esse é o andar errado da stack para começar. Antes de pegar um workflow engine, a pergunta que vale a pena responder é: de quantas primitivas eu realmente preciso para fazer "rode isso a cada minuto, exatamente uma vez, com failover" sobreviver a uma partição?

Três primitivas, mais um fence. As quatro vivem dentro do etcd, e o cliente Go as embala em APIs de 30 linhas. O resultado é um scheduler que cabe em um único arquivo, dá para ler num domingo de manhã e dá para raciocinar quando o líder trava.

Por que vale a pena escrever isso agora: o Thoughtworks Technology Radar Vol 34 moveu o Apache APISIX para Trial justamente porque ele usa o etcd para empurrar configuração de roteamento para os data planes sem a latência de um reload. Esse blip elevou o etcd de "detalhe de implementação do Kubernetes" para uma primitiva que engenheiros backend sêniores deveriam estar usando diretamente. O caso de uso do APISIX é broadcast de configuração; o caso de uso do cron com leader election é a mesma primitiva vista de outro ângulo.

## As quatro primitivas

**Lease.** Um lock com tempo de vida — mantido no servidor e renovado por um stream de keep-alive vindo do cliente. Se as renovações param de chegar (morte do processo, stall de rede, pausa de GC mais longa que o TTL), o servidor expira o lease e apaga toda chave anexada a ele. O cliente Go renova a cada TTL/3 por padrão.

**Election.** O pacote `concurrency` empacota um lease em uma eleição estilo CAS: candidatos escrevem uma chave sob um prefixo compartilhado com o lease deles anexado, e o candidato com a menor revisão de criação vence. `Campaign` ou retorna "você é o líder" ou bloqueia, observando até a sua vez chegar. `Resign` permite que um líder ceda o posto voluntariamente.

**Watch.** Toda mudança de chave no etcd é um evento lógico ordenado por revisão. O cliente Go faz stream desses eventos. A eleição usa watch internamente para saber quando a chave do líder anterior desaparece.

**Fencing token.** O lease ID do líder muda a cada sessão, mas o campo que nunca anda para trás entre trocas de liderança é `Election.Rev()` — a revisão do etcd na qual a chave do líder atual foi criada. É esse o inteiro que se persiste junto a qualquer trabalho que o líder faça, para que um líder antigo que travou e voltou não consiga sobrescrever a saída de um líder mais novo. A crítica do Martin Kleppmann a distributed locks-without-fencing se aplica diretamente aqui, e o campo de revisão do etcd é exatamente o inteiro monotônico que ele exige.

Esse é o conjunto completo de primitivas. Sem workflow engine, sem tabela de fila no Postgres.

## Um scheduler de arquivo único em Go

Aqui está a coisa toda. Ela assume um etcd local em `localhost:2379` — rodar `etcd --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://0.0.0.0:2379` já é o suficiente para acompanhar.

```go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
	"go.etcd.io/etcd/client/v3/concurrency"
)

const (
	electionPrefix = "/cron/scheduler/leader"
	tickInterval   = 1 * time.Minute
	sessionTTL     = 10 // seconds
)

func tick(at time.Time, fence clientv3.LeaseID, rev int64) {
	// In a real scheduler the work writes a row keyed by the tick timestamp,
	// guarded by "WHERE existing.fence < $rev" so a delayed old leader cannot
	// overwrite a newer one's output.
	log.Printf("tick at=%s lease=%d fence_rev=%d",
		at.UTC().Format(time.RFC3339), fence, rev)
}

func runAsLeader(ctx context.Context, sess *concurrency.Session, rev int64) {
	t := time.NewTicker(tickInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-sess.Done():
			// Lease expired or session closed. We are no longer leader.
			return
		case now := <-t.C:
			tick(now, sess.Lease(), rev)
		}
	}
}

func main() {
	nodeID := os.Getenv("NODE_ID")
	if nodeID == "" {
		nodeID = fmt.Sprintf("node-%d", os.Getpid())
	}

	cli, err := clientv3.New(clientv3.Config{
		Endpoints:   []string{"localhost:2379"},
		DialTimeout: 5 * time.Second,
	})
	if err != nil {
		log.Fatalf("etcd connect: %v", err)
	}
	defer cli.Close()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	for ctx.Err() == nil {
		sess, err := concurrency.NewSession(cli, concurrency.WithTTL(sessionTTL))
		if err != nil {
			log.Printf("session: %v — backing off", err)
			time.Sleep(2 * time.Second)
			continue
		}
		e := concurrency.NewElection(sess, electionPrefix)

		log.Printf("%s campaigning", nodeID)
		if err := e.Campaign(ctx, nodeID); err != nil {
			log.Printf("campaign: %v", err)
			sess.Close()
			continue
		}
		log.Printf("%s elected leader lease=%d rev=%d",
			nodeID, sess.Lease(), e.Rev())

		runAsLeader(ctx, sess, e.Rev())

		log.Printf("%s lost leadership lease=%d", nodeID, sess.Lease())
		sess.Close()
	}
}
```

Rode com:

```
go run main.go
```

Suba duas vezes, em dois terminais, como `NODE_ID=a go run main.go` e `NODE_ID=b go run main.go`. Um deles dá tick a cada minuto; o outro imprime `campaigning` e fica esperando. Dê Ctrl-C no líder e, dentro de aproximadamente o TTL da sessão, o segundo colocado loga sua eleição e começa a dar ticks.

Três linhas merecem um olhar mais atento — o resto é encanamento.

`concurrency.NewSession(cli, concurrency.WithTTL(sessionTTL))` faz silenciosamente duas coisas: cria um lease e inicia a goroutine de keep-alive que o renova. Com `sessionTTL = 10` segundos, o keep-alive roda a cada ~3,3 segundos, e uma expiração acontece entre 6,7 e 13,3 segundos depois da última renovação bem-sucedida — dependendo de qual keep-alive você perdeu.

`runAsLeader` faz select em `ctx.Done()` e `sess.Done()`. O segundo canal fecha quando a sessão expira, que é o único sinal de que perdi a liderança sem ter renunciado. Já vi novatos substituírem isso por uma chamada periódica de `IsLeader()`. Isso está errado: entre dois polls o lease pode expirar e um novo líder pode rodar. O canal `Done()` da sessão é a fonte da verdade.

`e.Rev()` é o fencing token que passo para `tick`. Em um scheduler real eu escrevo o resultado do tick em um store downstream com `INSERT (..., fence) VALUES (..., $rev) ON CONFLICT (key) DO UPDATE SET ... WHERE existing.fence < $rev`. Um líder que travou além do TTL e depois acordou ainda vai tentar escrever — e o fence da linha vai rejeitar a escrita porque ela carrega uma revisão mais antiga do que aquilo que o novo líder já comitou.

## O que acontece quando o líder morre no meio do tick

O modo de falha interessante não é o Ctrl-C limpo. É o líder que entra num stop-the-world de 12 segundos ou perde o uplink no segundo 31 de um intervalo de 60 segundos, no meio do tick. A sequência abaixo é o que de fato acontece. O diagrama torna o gap visível — uma vez que ele esteja no lugar, procure pela faixa vazia entre a expiração do lease de L1 e o primeiro tick de L2.

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1778080846353-gdks9cptp35.jpeg)

* Segundo 30. O líder L1 começa o tick #N com lease ID `0xAAA`, revisão de eleição 17.
* Segundo 31. L1 trava. Sua goroutine de keep-alive não consegue renovar.
* Segundo ~41. O lease expira no servidor; o etcd apaga a chave de eleição de L1. O watch acorda o segundo colocado L2.
* Segundo 41–42. L2 adquire sua própria sessão com lease ID `0xBBB`, vira líder na revisão 19, e inicia o ticker.
* Segundo 50. L1 acorda. O keep-alive retorna erro. `sess.Done()` fecha. O loop de `runAsLeader` sai, e L1 tenta escrever o resultado do tick #N no store downstream com fence revisão 17.
* Segundo 50,001. O store downstream rejeita a escrita porque a linha mais recente carrega fence revisão 19.

Dois fatos para guardar dessa cena. Primeiro, a janela de failover é limitada superiormente por `2 × TTL` e inferiormente por aproximadamente `TTL × 2/3` — assumindo que o stall aconteceu logo depois de uma renovação bem-sucedida. Com um TTL de 10 segundos, isso é uma janela de 6,7 a 13,3 segundos durante a qual nenhum nó segura o lease. Se o trabalho não tolerar esse gap, baixe o TTL — mas não empurre ele para menos do que 3× a pausa pior caso realista, incluindo pausas de GC e jitter de TLS handshake no runtime.

Segundo, é o fence que torna a escrita atrasada segura. Sem ele, o tick #N obsoleto de L1 sobrescreve silenciosamente o tick #N+1 fresco de L2. O FAQ do etcd é explícito sobre isso desde a versão 3.5: revisões são o fencing token. `Election.Rev()` é o que se persiste junto ao trabalho.

## Trade-offs que eu não pularia citar

**Drift de wall-clock entre líderes.** Cada líder roda seu próprio `time.Ticker`. Se L1 dispara em :00 e L2 assume em :12, o primeiro tick de L2 cai em :72 — e não em :00 do minuto seguinte, a menos que eu alinhe o ticker a uma fronteira de wall-clock. Para trabalho baseado em intervalo isso raramente importa; para schedules baseados em expressão cron, importa, e a correção é dormir até a próxima fronteira alinhada no topo de `runAsLeader` em vez de chamar `NewTicker` imediatamente.

**`time.Ticker` derruba ticks sob carga.** A docs do Go é explícita: se um receiver está ocupado quando um tick dispara, esse tick é descartado, não enfileirado. Para cron uma vez por minuto isso é irrelevante; para trabalho sub-segundo, é uma fonte real de ticks perdidos.

**Auto-renovação de lease durante turbulência no cluster do etcd.** A issue [#9888](https://github.com/etcd-io/etcd/issues/9888) no `etcd-io/etcd` descreve uma janela durante eleições do líder do cluster em que leases são estendidos automaticamente pelo novo líder do etcd. O TTL efetivamente alarga durante turbulências no cluster. Isso raramente é um bug de correção — o fence ainda segura — mas é o motivo pelo qual o failover durante uma partição real demora mais do que o limite superior `2 × TTL` calculado no guardanapo.

**O que isso não é.** O cron de líder único cobre "rode X a cada minuto, sem sobreposição, failover em menos de 15 segundos". Ele não me dá parsing de expressão cron, schedules com timezone, lógica de horário comercial, fairness multi-tenant, ou orçamento de retry por job. Esses são features que eu escreveria por cima — ou, no momento em que me pego escrevendo o terceiro deles, troco para uma biblioteca de scheduler de verdade.

## Dois testes que vale a pena manter

**Stall de renovação de lease.** Use `iptables -A OUTPUT -p tcp --dport 2379 -j DROP` (ou `tc` para introduzir delay) no líder, depois espere `1,5 × TTL`. Garanta que dentro de `2 × TTL` o segundo colocado venceu a eleição e começou os ticks, e que o `runAsLeader` do líder original saiu via `sess.Done()`. Esse é o teste que pega o bug do "esqueci de escutar `sess.Done()` e usei um boolean de polling".

**Tick duplicado em split-brain.** Rode três nós com TTL de 5s. Derrube a rede entre o líder e o etcd por `2 × TTL + 2`. Restabeleça. Garanta que o store downstream tenha exatamente uma linha por intervalo de tick, e que qualquer escrita duplicada tenha sido rejeitada pela coluna de fence. Esse é o teste que prova que o fencing token está fazendo o trabalho dele. Se duas linhas existem, o downstream está sem o guard `WHERE existing.fence < $rev`.

Eu não rodo nenhum dos dois em um teste unitário — eles precisam de um etcd de verdade. O primeiro eu rodo dentro de um `docker compose` com três nós etcd e um passo pequeno de netem com `pumba`. O segundo precisa de um Postgres sidecar ou seja lá o que for que esteja recebendo as linhas com fence.

## Onde isso deixa de ser suficiente

A linha em que eu abandonaria esse design e puxaria um workflow runtime não é "mais de um job". Sharding de ticks entre líderes por chave de job é um adendo de 30 linhas: faça hash do job ID, módulo a contagem de líderes, guarde um prefixo de eleição por shard. A linha é quando ticks individuais precisam de máquinas de estado duráveis e replayáveis: workflows de múltiplos passos onde um único tick gera uma saga que dura 20 minutos, fala com sete serviços, e precisa retomar entre reinícios de processo sem refazer os efeitos colaterais.

É para isso que DBOS, Temporal e Restate foram construídos. O cron com leader election em etcd é o que eles assentam _por baixo_ nos próprios deployments deles. A query `dbos ontime` deixa o leitor no andar errado dessa stack — a resposta não é um workflow engine, é a primitiva que o workflow engine usa internamente para agendar os próprios ticks.

## Quando usar isso e quando evitar

Use quando:

* o etcd já está no footprint operacional (Kubernetes, APISIX, um service mesh — mesmo cluster, prefixo de chave isolado).
* a unidade de trabalho cabe dentro de um intervalo de tick e é idempotente na camada de storage.
* uma janela de failover de 6 a 15 segundos é aceitável.
* o schedule é um intervalo fixo ou um punhado pequeno de entradas cron.

Evite quando:

* o job é multi-passo, longo, e precisa de replay durável. Use Temporal, DBOS ou Restate.
* precisão sub-segundo é exigida. A matemática do TTL não vai entregar isso.
* o etcd não está sendo operado. Subir um cluster só para rodar cron é uma má troca contra um advisory lock do Postgres mais um guard de uma linha por tick.

## Pontos a levar daqui

* Um lease do etcd, `concurrency.Election` e `sess.Done()` juntos cobrem ticks recorrentes exactly-once em menos de 100 linhas de Go.
* O fencing token a persistir é `Election.Rev()`. Sem ele, um líder travado pode silenciosamente sobrescrever a saída de um líder mais fresco.
* A janela de failover é limitada por aproximadamente `2 × TTL`. Calibre o TTL da sessão contra a pausa pior caso realista, não contra a média.
* Escute em `sess.Done()`, nunca faça poll em um boolean de liderança. Polling tem uma janela em que dois nós acreditam estar segurando o lock.
* A linha em que isso deixa de ser suficiente é workflow durável multi-passo, não "mais jobs". Adicione sharding antes de adicionar um workflow engine.
