Pular para o conteúdo principal
Todos os Posts
Engineering12 min de leitura

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.

Tiarê Balbi BonaminiEngenheiro de Software · Vancouver
2/4

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.

O que `dbos ontime` realmente está perguntando: construindo um cron distribuído com leases do etcd em Go

  • 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 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.
Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.