Honker - extensão que implementa Postgres NOTIFY/LISTEN no SQLite
(github.com/russellromney)- Integra em um único arquivo SQLite uma fila durável, streams, pub/sub e agendador, permitindo processar tarefas assíncronas sem brokers separados como Redis ou Celery
- Faz polling de
PRAGMA data_versiona cada 1 ms para alcançar tempo de resposta entre processos na faixa de milissegundos de um dígito, sem necessidade de polling no nível da aplicação ou de daemon notify(),stream()equeue()são todos registrados dentro da transação do chamador, sendo confirmados junto com as escritas de negócio ou revertidos juntos, reduzindo o problema de dual-write- A fila de tarefas inclui tentativas de repetição, prioridade, execução com atraso, dead-letter, scheduler, named lock e rate limiting, e os streams oferecem entrega at-least-once com armazenamento de offset por consumidor
- Em ambientes que usam SQLite como armazenamento principal, é possível reunir a aplicação e o processamento assíncrono em um único arquivo de banco de dados, reduzindo a complexidade operacional
- Fornece três primitivas principais
- queue(): fila de tarefas at-least-once — tentativas, prioridade, tarefas com atraso, dead-letter, visibility timeout
- stream(): pub/sub durável — rastreamento de offset por consumidor, replay at-least-once
- notify(): pub/sub efêmero — fire-and-forget, sem replay de histórico
- Com o decorador
@queue.task()no estilo Huey, transforma funções em tarefas de fila e oferece suporte a trabalhos periódicos com base emcrontab()+ scheduler com eleição de líder - O esquema da fila aplica partial index na tabela
_honker_live; o claim é feito com um únicoUPDATE … RETURNINGe o ack com um únicoDELETE, mantendo desempenho constante independentemente da quantidade de linhas mortas - Como extensão carregável do SQLite (
libhonker_ext), permite acesso às mesmas tabelas em todos os clientes SQLite 3.9+ — um worker em Python pode fazer claim de tarefas enviadas por outra linguagem - Fornece guias de integração com SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord e Ecto, entre outros ORMs importantes
- Até transações durante SIGKILL permanecem seguras graças ao ACID do SQLite, e quando um worker falha o claim é retomado automaticamente após o vencimento do visibility timeout
- Oferece bindings para 8 linguagens: Python, Node.js, Rust, Go, Ruby, Bun, Elixir e C++, cada uma publicada separadamente em PyPI, npm, crates.io, Hex e RubyGems
- Implementado em Rust (
honker-core + honker-extension) - Licença Apache 2.0
1 comentários
Comentários do Hacker News
Eu fiz isso. O Honker adiciona NOTIFY/LISTEN entre processos ao SQLite, entregando eventos em estilo push com latência de poucos milissegundos usando apenas o arquivo SQLite existente, sem daemon nem broker
Como o SQLite não tem servidor como o Postgres, a ideia central foi mover a fonte de polling para um
stat(2)leve no arquivo WAL, em vez de consultar periodicamente. Como o SQLite é eficiente mesmo com muitas consultas pequenas (https://www.sqlite.org/np1queryprob.html), não dá para dizer que isso é uma melhoria gigantesca, mas é interessante que seja independente de linguagem, já que basta observar o WAL e chamar funções do SQLiteTambém coloquei em cima disso pub/sub efêmero, fila de trabalho durável com retry e dead-letter, e stream de eventos com offsets por consumidor. Os três são linhas dentro do arquivo
.dbdo app existente, então podem ser commitados atomicamente com as escritas de negócio, e se houver rollback os dois desaparecem juntosOriginalmente era litenotify/joblite, mas eu tinha comprado
honker.devde brincadeira e, vendo que nomes como Oban, pg-boss, Huey, RabbitMQ, Celery e Sidekiq também são todos meio engraçados, acabei ficando com esse mesmo. Espero que seja útil ou pelo menos engraçado, e o aviso de software alfa continua valendoEm coisas como Java/Go/Clojure/C#, como o SQLite de qualquer forma tem um único writer, parece mais simples e limpo a aplicação gerenciar esse writer e usar alguma fila concorrente da própria linguagem para saber que escritas aconteceram e acordar só as threads relevantes
Ainda assim, é divertido ver o WAL sendo usado dessa forma criativa, e em linguagens como Python/JS/TS/Ruby, onde concorrência baseada em processos é comum, isso parece encaixar bem como mecanismo de notificação
stat()a cada 1 ms é surpreendentemente baratoNo meu hardware leva menos de 1 μs por chamada, então esse nível de polling nem chega a 0,1% de uso de CPU
PRAGMA data_versionseria melhor questat(2)https://sqlite.org/pragma.html#pragma_data_version
Na API C também existe o mais direto
SQLITE_FCNTL_DATA_VERSIONhttps://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
Fico curioso se isso também pode ser usado como um stream persistente de mensagens, tipo um Kafka leve. Queria saber se também dá para ter semântica de replay de todas as mensagens passadas + em tempo real de um certo tópico a partir de um timestamp específico
Dá para imitar isso com polling, como no pub/sub, mas como você disse provavelmente não seria o ideal
Se você guardar posição de leitura, nome da fila e filtros, em vez de acordar todas as threads de subscription a cada mudança no
stat(2)para cada uma fazer seu próprio SELECT com N=1, a thread de polling poderia fazerEvents INNER JOIN Subscriberse acordar só os subscribers que realmente combinamValeu pelo feedback. Abri um PR incorporando as sugestões
https://github.com/russellromney/honker/pulls/1
Agora mudou para uma estrutura de polling em 3 camadas:
PRAGMA data_versiona cada 1 ms,stata cada 100 ms e tratamento de reconexão em caso de erroPRAGMA data_versiona cada 1 ms para substituir a detecção anterior de mudança de size/mtime baseada emstat. Como é o commit counter do próprio SQLite, ele é monotônico, não sofre com clock skew e lida corretamente com truncamento do WAL e rollback. É uma consulta nonblocking de cerca de 3 µs, e troquei isso não por desempenho, mas por correção. Na verdade é até um pouco mais lento. O risco de truncamento acabou sendo mais real do que eu imaginavaNos testes, o
SQLITE_FCNTL_DATA_VERSIONda API C não funcionou entre conexões. Então, por enquanto, ainda estou pagando o custo de passar pela camada VFS e aceitando explicitamente esse tradeoffdata_versionfalhar, assumo casos como erro temporário de disco, hiccup no NFS ou corrupção da conexão, tento reconectar e, por precaução, também acordo os subscribersstatpara comparar(dev, ino)com os valores do startup e detectar substituição de arquivo. Isso cobre casos como atomic rename, restore do litestream e remount de volume. Odata_versionsegue o fd aberto, então mesmo se o arquivo mudar ele continua vendo o inode original, e por isso não detecta issoCom isso o Honker ficou melhor, e eu também aprendi bastante
Fazendo uma propaganda discreta: no futuro PostgreSQL 19, o LISTEN/NOTIFY foi otimizado para escalar muito melhor em signaling seletivo
É um patch voltado para casos em que muitos backends ficam escutando canais diferentes
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9
Fico pensando se não daria para observar mudanças no WAL com inotify ou algum wrapper multiplataforma, sem polling
statsimplesmente funciona em todo lugarO atrativo em relação a IPC separado é que isso fica em commit atômico com os dados de negócio
Entrega externa de mensagens sempre tem o problema de "a notificação foi, mas a transação sofreu rollback", e isso fica bagunçado rápido
Uma coisa que eu queria saber é sobre checkpoint do WAL. Quando o SQLite trunca o WAL de volta para 0, não sei se o polling com
stat()lida corretamente com isso. Tenho a impressão de que pode haver uma janela em que eventos sejam perdidosJá sofri antes com uma combinação Postgres+SQS em que o enqueue era disparado por trigger em outra connection antes de o commit ficar visível. Coloquei lógica de retry, adicionei polling do lado do worker e, no fim, movi o enqueue para dentro da transação; mas aí você basicamente acaba recriando o que o Honker faz, só que com mais moving parts
Bugs do tipo "a notificação foi, mas a linha ainda não foi commitada" costumam ser silenciosos e dependentes de timing, então são realmente horríveis de rastrear
Dito isso, ainda não há teste para essa parte, então preciso confirmar melhor. Bom ponto, vou verificar
Valeu
Houve um grande aumento de apps pequenos baseados em SQLite, e a maioria deles precisa de fila e scheduler
Já rodei algumas coisas por conta própria, mas sempre senti falta da elegância das soluções da família Postgres
Pretendo testar isso logo
Se topar com algum problema, seria ótimo se abrisse um PR ou issue no repositório
Dá vontade de usar kqueue/FSEvents aqui, mas, pelo que eu sabia, no Darwin ele descarta notificações do mesmo processo
Se publisher e listener estiverem no mesmo processo, às vezes o listener nem acorda, e isso fica bem chato de rastrear. Por mais feio que o polling com
statpareça, no fim parece ser o que realmente funciona em qualquer lugarTambém fiquei curioso se, quando o arquivo encolhe de novo no checkpoint do WAL, isso gera wakeup, ou se o poller filtra redução de tamanho
Eventos VNODE do kqueue são entregues desde que o processo tenha permissão de acesso ao arquivo; não existe filtro que descarte por ser o mesmo processo
Vou verificar e depois volto para contar
Muito legal. Fico curioso se, sob carga, o gargalo fica mais no throughput de escrita do SQLite ou na camada de notificação do WAL
Também varia bastante conforme o journal mode e o synchronous mode
A notificação, seja no esquema antigo com
stat(2)ou no novo baseado emPRAGMA, é muito barata. Em outro comentário também foi dito questat(2)fica na casa de ~1 µsProjeto muito bom. Eu também estou construindo algo que força o SQLite bem além do uso típico
É animador ver mais gente explorando até onde o SQLite realmente consegue ir
Fico curioso se dá para integrar isso também quando se usa SQLAlchemy
Do jeito que está agora, parece que ele quer criar a própria connection ao banco