Fila de Automação com SQLite: Como Criar Jobs Resilientes Sem Montar um Circo de Infraestrutura
Fila de automação com SQLite parece gambiarra até o dia em que seu fluxo principal cai no meio de uma madrugada, o webhook responde 500, o n8n fica reiniciando, e você descobre que “rodar de novo” não é uma estratégia: é roleta-russa com logs.
Eu aprendi isso do jeito idiota, que é o jeito mais eficiente e mais caro. Tinha uma automação simples: receber eventos, enriquecer dados, chamar uma API externa e salvar o resultado. Nada com cheiro de foguete da NASA. Só que a API externa oscilava, o processo morria no meio, e algumas tarefas sumiam como se tivessem entrado numa gaveta do Detran. Foi aí que parei de confiar em “scripts heroicos” e montei uma fila local, pequena, auditável e teimosamente eficiente usando SQLite.
Este texto é um laboratório prático do Lab da Garra: vamos construir uma fila de automação com SQLite para jobs resilientes, com retry, backoff, trava contra execução duplicada e uma forma decente de debugar quando tudo der errado. Porque vai dar errado. A diferença é se você vai perder trabalho ou só perder um pouco de respeito próprio lendo os logs.

O problema real: automações que esquecem trabalho
A maioria das automações caseiras nasce como um script direto: pega entrada, faz alguma coisa, envia resultado. Enquanto tudo funciona, parece elegante. O problema começa quando uma etapa intermediária falha. Se a entrada veio por webhook e você respondeu sucesso antes de terminar, talvez nunca consiga processar de novo. Se você respondeu erro, talvez o serviço remetente tente novamente e duplique tudo. Se você processa em lote, talvez o item 38 quebre e os itens 39 a 120 fiquem reféns do drama.
O erro mental é tratar automação como uma linha reta. Automação de verdade é uma esteira: itens entram, aguardam, são processados, falham, voltam, expiram, são investigados. A fila é o lugar onde você admite que o mundo é imperfeito sem precisar escrever uma tese sobre sistemas distribuídos.
Ferramentas como Redis, RabbitMQ, SQS e Kafka resolvem isso muito bem. Mas nem todo projeto merece uma cerimônia de infraestrutura. Às vezes você tem um VPS, um container, um cron e uma necessidade simples: não perder jobs. Para isso, SQLite é excelente. Ele é chato, previsível, transacional e cabe em um arquivo. Basicamente o oposto de muita arquitetura moderna com 14 dashboards e zero responsabilidade.
Quando SQLite faz sentido — e quando não faz
SQLite funciona muito bem para filas locais de baixo a médio volume, especialmente quando existe um único host executando os workers. Ele brilha em automações internas, rotinas de integração, robôs de monitoramento, processamento assíncrono leve e pipelines pequenos. Se você já construiu algo como o robô de monitoramento do post Como Construí um Robô de Monitoramento que Funciona Enquanto Você Dorme, uma fila local é o próximo passo natural.
Não use SQLite como fila se você precisa de dezenas de máquinas concorrendo em alta frequência, throughput absurdo, fanout complexo ou retenção distribuída. Aí você quer outra coisa. Mas para “tenho 500 tarefas por dia e não posso perdê-las”, SQLite é mais que suficiente. E, honestamente, mais fácil de entender às 2h da manhã do que uma stack de mensageria configurada por alguém que saiu da empresa.
O modelo mental da fila
Uma fila resiliente precisa guardar mais do que o payload. Ela precisa saber o estado do job, quantas tentativas já foram feitas, quando ele pode rodar de novo, se alguém está processando agora e qual foi o último erro. Sem isso, você só tem uma tabela triste com JSON dentro.
Vamos usar estes estados:
- pending: job pronto para ser executado.
- processing: algum worker pegou o job.
- done: terminou com sucesso.
- failed: estourou o número de tentativas.
Também teremos run_after, que permite retry com atraso, e locked_at, que evita que dois workers peguem a mesma tarefa ao mesmo tempo. Essa trava não precisa ser mística. Precisa ser transacional.
Criando o banco da fila
O schema abaixo é propositalmente pequeno. Dá para colocar mais campos depois, mas comece com algo que você consiga explicar sem abrir uma apresentação no Figma.
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
run_after TEXT NOT NULL DEFAULT (datetime('now')),
locked_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_jobs_pick
ON jobs (status, run_after, id);
CREATE INDEX IF NOT EXISTS idx_jobs_locked
ON jobs (status, locked_at);
Repare no índice idx_jobs_pick. Sem ele, sua fila pequena vira uma fila lerda conforme cresce. Otimização prematura é pecado, mas índice óbvio em coluna de busca não é otimização prematura; é higiene.
Inserindo jobs sem drama
Um produtor de jobs pode ser um webhook, um cron ou outro script. Para este laboratório, vamos usar um comando simples em Python. Sim, dá para fazer em Bash puro com sqlite3, mas JSON escapado em Bash é uma punição que ninguém deveria cumprir em produção.
#!/usr/bin/env python3
import json
import sqlite3
import sys
DB = "queue.db"
def enqueue(job_type, payload):
con = sqlite3.connect(DB)
con.execute(
"INSERT INTO jobs (type, payload) VALUES (?, ?)",
(job_type, json.dumps(payload, ensure_ascii=False)),
)
con.commit()
con.close()
if __name__ == "__main__":
enqueue("send_report", {"email": sys.argv[1], "report_id": sys.argv[2]})
print("job enfileirado")
O ponto importante: o produtor não executa o trabalho pesado. Ele só registra a intenção. Isso muda tudo. Se o worker cair, o job continua lá. Se a API externa ficar fora, o job continua lá. Se você fizer besteira, pelo menos a besteira tem ID.
Pegando um job com trava atômica
Agora vem a parte que separa fila de brinquedo de fila minimamente séria: o worker precisa pegar um job sem disputar com outro worker. Em SQLite moderno, podemos usar uma transação imediata para bloquear escrita enquanto selecionamos e marcamos o job como processing.
import sqlite3
from datetime import datetime, timezone
DB = "queue.db"
STALE_MINUTES = 15
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
def pick_job():
con = sqlite3.connect(DB, timeout=30)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("BEGIN IMMEDIATE")
job = con.execute("""
SELECT * FROM jobs
WHERE status = 'pending'
AND run_after <= datetime('now')
ORDER BY id ASC
LIMIT 1
""").fetchone()
if not job:
con.commit()
con.close()
return None
con.execute("""
UPDATE jobs
SET status = 'processing', locked_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?
""", (job["id"],))
con.commit()
con.close()
return dict(job)
O BEGIN IMMEDIATE é o detalhe que evita a palhaçada clássica: dois workers leem o mesmo job como pendente e ambos tentam processar. Concorrência é divertida até virar boleto.
Processando com retry e backoff
Um retry decente não tenta novamente em loop frenético. Isso só transforma uma falha externa em DDoS acidental, que é uma forma elegante de dizer “fui incompetente com entusiasmo”. Use backoff: a cada falha, espere mais.
import json
import random
import sqlite3
import time
DB = "queue.db"
def mark_done(job_id):
with sqlite3.connect(DB) as con:
con.execute("""
UPDATE jobs
SET status = 'done', updated_at = datetime('now')
WHERE id = ?
""", (job_id,))
def mark_failed_or_retry(job, error):
attempts = job["attempts"] + 1
final = attempts >= job["max_attempts"]
if final:
sql = """
UPDATE jobs
SET status = 'failed', attempts = ?, last_error = ?,
locked_at = NULL, updated_at = datetime('now')
WHERE id = ?
"""
params = (attempts, str(error), job["id"])
else:
delay = min(3600, (2 ** attempts) * 30) + random.randint(0, 20)
sql = """
UPDATE jobs
SET status = 'pending', attempts = ?, last_error = ?, locked_at = NULL,
run_after = datetime('now', ?), updated_at = datetime('now')
WHERE id = ?
"""
params = (attempts, str(error), f"+{delay} seconds", job["id"])
with sqlite3.connect(DB) as con:
con.execute(sql, params)
def handle(job):
payload = json.loads(job["payload"])
# Aqui entra a chamada real: API, relatório, scraping, upload etc.
print("processando", job["type"], payload)
while True:
job = pick_job()
if not job:
time.sleep(5)
continue
try:
handle(job)
mark_done(job["id"])
except Exception as exc:
mark_failed_or_retry(job, exc)
O jitter aleatório no atraso evita que vários jobs falhos voltem exatamente no mesmo segundo. É um detalhe pequeno, mas sistemas reais morrem de detalhes pequenos. Depois todo mundo chama de “incidente complexo” para parecer menos constrangedor.
Box perrengue: o job fantasma em processing
Uma rotina simples de requeue resolve:
UPDATE jobs
SET status = 'pending',
locked_at = NULL,
last_error = 'requeued after stale processing lock',
run_after = datetime('now'),
updated_at = datetime('now')
WHERE status = 'processing'
AND locked_at < datetime('now', '-15 minutes');
Rode isso no início do worker ou em um cron separado. O tempo de 15 minutos depende do seu job. Se seus jobs normalmente levam 30 minutos, use outro limite. O importante é não deixar zumbis eternos na tabela.
Idempotência: a trava que evita duplicar desastres
Retry sem idempotência é uma granada com botão de soneca. Se o job envia e-mail, cobra cartão, cria ticket ou publica conteúdo, repetir a operação pode causar dano real. A fila garante que você não perca o job; ela não garante, sozinha, que a ação seja segura para repetição.
No post Idempotência em Automações: Como Fazer Retries Sem Duplicar Desastres, eu entro mais fundo nesse tema. Aqui vai a versão curta: cada job que pode gerar efeito externo precisa de uma chave idempotente. Pode ser event_id, order_id, report_id ou uma hash do payload. Antes de executar, verifique se aquele efeito já foi aplicado.
CREATE TABLE IF NOT EXISTS job_effects (
idempotency_key TEXT PRIMARY KEY,
job_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
def reserve_effect(con, key, job_id):
try:
con.execute(
"INSERT INTO job_effects (idempotency_key, job_id) VALUES (?, ?)",
(key, job_id),
)
return True
except sqlite3.IntegrityError:
return False
Se a chave já existe, você não executa o efeito de novo. Marca como concluído ou investiga, dependendo do caso. Isso é especialmente importante em integrações financeiras, notificações e qualquer coisa que mande mensagem para humanos. Humanos não gostam de receber 17 alertas porque seu retry achou que insistência era arquitetura.
Observabilidade sem virar detetive
Uma fila local precisa ser fácil de inspecionar. O melhor dashboard inicial é uma consulta SQL bem escrita. Antes de instalar qualquer ferramenta bonitinha, aprenda a perguntar para o banco o que está acontecendo.
SELECT status, COUNT(*) AS total
FROM jobs
GROUP BY status
ORDER BY total DESC;
SELECT id, type, attempts, run_after, last_error
FROM jobs
WHERE status IN ('pending', 'failed')
ORDER BY updated_at DESC
LIMIT 20;
Se você quiser algo mais confortável, gere um relatório HTML simples ou exponha uma rota interna protegida. Só não caia no vício de transformar todo problema em dashboard. Às vezes o que falta não é gráfico; é uma mensagem de erro que não pareça psicografada por um roteador.
Para investigar melhor logs de produção, vale conectar essa fila com as práticas do artigo Observabilidade de Logs: Como Debugar Produção Sem Virar Detetive de Madrugada. Inclua sempre job_id, type, attempts e idempotency_key nos logs. Sem correlação, debug vira espiritismo.
Executando com systemd ou cron
Para laboratório, rodar o worker no terminal basta. Para produção pequena, eu prefiro systemd porque ele reinicia processo, centraliza logs e não finge que cron é supervisor. Um serviço mínimo ficaria assim:
[Unit]
Description=AutoMente SQLite Queue Worker
After=network-online.target
[Service]
WorkingDirectory=/opt/automente-worker
ExecStart=/usr/bin/python3 worker.py
Restart=always
RestartSec=5
User=automente
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
Se você estiver em container, o processo principal pode ser o worker. Se estiver em VPS clássico, systemd resolve. O ponto é: não deixe uma automação importante dependendo de uma aba de terminal aberta. Isso não é operação, é superstição com prompt colorido.

Checklist de produção mínima
Antes de colocar essa fila para cuidar de algo útil, valide estes pontos:
- O banco está em volume persistente, não dentro de camada descartável do container.
- O worker usa transação para pegar jobs.
- Existe retry com backoff e limite de tentativas.
- Jobs travados em
processingsão resgatados. - Ações externas perigosas têm idempotência.
- Logs incluem
job_ide erro completo. - Existe consulta ou alerta para jobs em
failed. - Backups incluem o arquivo SQLite se os jobs forem importantes.
Esse checklist parece óbvio. Ótimo. O óbvio escrito salva mais sistema do que arquitetura genial guardada na cabeça de alguém.
Como evoluir sem transformar em monstro
Depois que a fila funciona, a tentação é adicionar prioridade, múltiplos tipos de worker, dead letter queue, métricas, painel, API administrativa, replay seletivo e talvez uma mascote. Respire. Evolua quando a dor aparecer, não quando a ansiedade arquitetural bater.
Melhorias úteis e ainda simples:
- Prioridade: adicionar coluna
prioritye ordenar por ela antes doid. - Dead letter: manter jobs
failede criar comando manual de replay. - Tipos separados: rodar workers diferentes filtrando por
type. - Métricas: contar tempo médio entre
created_atedone. - Arquivo de payload: guardar payload grande em arquivo e manter só referência no banco.
Se essas melhorias começarem a virar uma plataforma paralela, talvez seja hora de migrar para uma fila dedicada. Isso não invalida o SQLite; significa que ele cumpriu seu papel até a complexidade justificar outra ferramenta. Ferramenta boa também sabe a hora de sair de cena.
Conclusão: a fila é pequena, o alívio é grande
Uma fila de automação com SQLite não é a solução mais glamourosa. Ninguém vai te chamar para palestra só porque você colocou jobs em uma tabela. Mas ela resolve um problema brutalmente comum: automações que perdem trabalho, duplicam efeitos ou morrem sem deixar rastro decente.
O valor está na mudança de postura. Em vez de acreditar que tudo vai funcionar, você assume que falhas são parte do fluxo. Em vez de retry cego, você cria backoff. Em vez de “acho que rodou”, você tem estado. Em vez de pânico, você tem uma consulta SQL.
Se você tem uma automação que hoje roda no improviso — um script de relatório, um robô de alerta, um importador, um webhook, um integrador de APIs — experimente colocar uma fila no meio. Comece pequena. Um arquivo SQLite, uma tabela, um worker. O ganho de confiabilidade é desproporcional ao esforço.
CTA: qual automação você quer ver desmontada e reconstruída aqui no AutoMente? Um pipeline de scraping, um robô de WhatsApp, um monitor de servidor, uma esteira de publicação ou alguma outra criatura que hoje vive na base do “tomara que funcione”?
