O que `dbos ontime` realmente está perguntando: construindo um cron distribuído com leases do etcd em Go
Uma busca 0-click por `dbos ontime` apareceu no meu Search Console na semana passada. Quem digitou isso não está perguntando sobre DBOS — está perguntando como rodar um job a cada minuto, exatamente uma vez, em uma frota de máquinas. Pelas minhas próprias anotações, um lease do etcd, o pacote `concurrency.Election` e um fencing token cobrem esse caso em menos de 100 linhas de Go, sem precisar trazer um workflow engine.
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.
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.

- 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 derunAsLeadersai, 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 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.Electionesess.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.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
DBOS vs Temporal: Quando o Postgres É Suficiente para Execução Durável de Workflows
O DBOS reutiliza o Postgres como camada de durabilidade para workflows, enquanto o Temporal roda um cluster dedicado. A escolha certa depende do tamanho do time, da forma do workload e de onde você quer que seu orçamento operacional vá. Este é um critério prático para escolher entre eles.
Arquitetura Baseada em Células Não É de Graça: O Que Slack, DoorDash e Roblox Realmente Pagaram Por Ela
Arquitetura baseada em células contém o raio de impacto, mas não é gratuita. Um olhar sobre o que Slack, DoorDash e Roblox realmente pagaram por células em produção — e um checklist para os padrões mais baratos de isolamento de falhas que a maioria dos times deveria considerar primeiro.
O Transactional Outbox Não É uma Fila
O transactional outbox é um ledger, não uma fila. Tratá-lo como fila é o que quebra o Postgres sob carga. Este post percorre os modos de falha específicos — autovacuum travando, drift do horizonte xmin, lag do replication slot, poison pills — e as regras operacionais que realmente o mantêm funcionando em produção.