Honker - Extensão que adiciona semântica de NOTIFY/LISTEN do Postgres ao SQLite
(github.com/russellromney)- Extensão do SQLite e bindings para várias linguagens permitem tratar pub/sub durável, fila de tarefas e stream de eventos dentro do mesmo arquivo
.db, sem polling do cliente nem daemon·broker separado notify(),stream()equeue()são todos gravados dentro da transação de quem chama, sendo commitados junto com as escritas de negócio ou revertidos juntos, reduzindo o problema de dual-write- O despertar entre processos funciona verificando
PRAGMA **data_version**a cada 1 ms, com meta de latência de milissegundos de um dígito e custo de consulta muito baixo - A fila de tarefas inclui tentativas, prioridade, execução atrasada, dead-letter, scheduler, named lock e rate limiting, e os streams oferecem entrega at-least-once com offsets salvos por consumidor
- Em ambientes que usam SQLite como armazenamento principal, é uma configuração que une a aplicação e o processamento assíncrono em um único arquivo de banco de dados, reduzindo a complexidade operacional, embora a API ainda esteja em estado Experimental
Visão geral
- Com uma extensão do SQLite e bindings para várias linguagens, adiciona ao SQLite o comportamento de
NOTIFY/LISTENno estilo do Postgres e permite processar pub/sub durável, filas de tarefas e streams de eventos dentro do mesmo arquivo.db, sem polling do cliente nem daemon·broker separado - Com base em um layout on-disk definido uma única vez em Rust, os bindings de Python, Node, Bun, Ruby, Go, Elixir e C++ foram estruturados para encapsular de forma leve a mesma loadable extension
- Substitui o polling em nível de aplicação por uma abordagem que lê o banco a cada 1 ms; o custo de consultar
PRAGMA data_versionfica na faixa de microssegundos de um dígito, e a entrega de notificações entre processos fica na faixa de milissegundos de um dígito - Ao usar SQLite como armazenamento principal, é possível commitar ou reverter na mesma transação as escritas de negócio e a inserção na fila, reduzindo a necessidade de operar um datastore separado e os problemas de dual-write
- A API ainda está em estado Experimental e pode mudar
- Também deixa claro que, se você já opera Postgres, usar
pg_notify, pg-boss e Oban pode ser mais adequado
Principais recursos
- Oferece, em um único arquivo
.db, notify/listen entre processos, fila de tarefas com tentativas, prioridade, execução atrasada e tabelas dead-letter, além de stream durável com offsets por consumidor - Todas as operações de envio podem ser combinadas atomicamente com as escritas de negócio, sendo commitadas ou revertidas juntas
- O tempo de resposta entre processos fica na faixa de milissegundos de um dígito, e também inclui handler timeout, tentativas com exponential backoff, delayed jobs, expiração de tarefas, named lock e rate limiting
- Também oferece scheduler baseado em leader election, tarefas periódicas em estilo crontab e armazenamento opt-in do resultado das tarefas
enqueueretorna um id, o worker armazena o valor de retorno e quem chamou pode esperar o resultado comqueue.wait_result(id)
- Fornece uma SQLite loadable extension, para que qualquer cliente SQLite possa ler as mesmas tabelas
- Também funciona dentro de conexões SQLite controladas por ORM, e o guia de ORM aborda integração com SQLAlchemy, SQLModel, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord e Ecto
- Em contrapartida, também deixa claro o que está intencionalmente fora do escopo
- não oferece suporte a task pipeline, chain, group ou chord
- não oferece suporte a replicação multi-writer
- não oferece suporte a workflow orchestration baseada em DAG
Início rápido
-
Fila em Python
- Abra o banco de dados com
honker.open("app.db")e obtenha uma fila comodb.queue("emails")para enfileirar e consumir trabalhos - Se você executar o INSERT do pedido e
emails.enqueue(..., tx=tx)juntos dentro do blocowith db.transaction() as tx:, a gravação do pedido e o enfileiramento da tarefa de e-mail ficam agrupados na mesma transação - O worker busca os trabalhos um a um no formato
async for job in emails.claim("worker-1"):e processa comjob.ack()em caso de sucesso oujob.retry(delay_s=60, error=str(e))em caso de falha claim()é um iterador assíncrono e internamente chamaclaim_batch(worker_id, 1)a cada iteração- Ele desperta em qualquer commit no banco de dados e só recorre a um paranoia poll de 5 segundos quando o commit watcher não consegue funcionar
- O processamento em lote foi separado para usar diretamente
claim_batch(worker_id, n)equeue.ack_batch(ids, worker_id), e a visibility padrão é de 300 segundos
- Abra o banco de dados com
-
Tasks em Python
- Com o decorador
@emails.task(retries=3, timeout_s=30), a chamada da função passa a virar diretamente um enfileiramento, retornandoTaskResult - Quem chama pode usar como em
send_email("alice@example.com", "Hi")e esperar o resultado da execução do worker comr.get(timeout=10) - O worker pode ser executado como processo separado ou in-process, por exemplo
python -m honker worker myapp.tasks:db --queue=emails --concurrency=4 - O nome automático é
{module}.{qualname}e, em produção, recomenda-se usar um nome explícito como@emails.task(name="...")para evitar que jobs pendentes fiquem órfãos por causa de mudança de nome - Tasks periódicas usam o formato
@emails.periodic_task(crontab("0 3 * * *")) - Há exemplos detalhados em
packages/honker/examples/tasks.py
- Com o decorador
-
Streams em Python
db.stream("user-events")fornece pub/sub durável, e é possível executar o UPDATE de negócio estream.publish(..., tx=tx)na mesma transação- Ao assinar com
async for event in stream.subscribe(consumer="dashboard"), ele reproduz as linhas após o offset salvo e depois muda para entrega em tempo real baseada em commit - O offset de cada consumer nomeado é armazenado na tabela
_honker_stream_consumers - O salvamento automático do offset ocorre, por padrão, só a cada 1000 eventos ou uma vez por segundo, para não pressionar excessivamente o slot de single-writer mesmo em alto throughput
- É possível ajustar com
save_every_n=esave_every_s=; se ambos forem 0, o salvamento automático é desativado estream.save_offset(consumer, offset, tx=tx)pode ser chamado manualmente - Em caso de crash, segue o modelo at-least-once, em que eventos in-flight após o último offset persistido são entregues novamente
-
Notify em Python
- É possível assinar pub/sub efêmero com
async for n in db.listen("orders"):e enviar notificações comtx.notify("orders", {"id": 42})dentro da transação - O listener se conecta a partir do ponto atual de
MAX(id), portanto não reproduz histórico anterior - Se for necessário replay durável, deve-se usar
db.stream() - A tabela de notifications não é limpa automaticamente, então é preciso chamar
db.prune_notifications(older_than_s=…, max_keep=…)em uma tarefa agendada - O payload da task deve ser válido como JSON, e um writer em Python e um reader em Node podem compartilhar o mesmo canal
- É possível assinar pub/sub efêmero com
-
Node.js
- O binding de Node também usa o mesmo padrão com
open('app.db'),db.transaction(),tx.notify(...)edb.listen('orders') - A escrita de negócio e o notify ficam agrupados no mesmo commit, e o listen desperta em qualquer commit do banco antes de filtrar pelo canal
- O binding de Node também usa o mesmo padrão com
-
Extensão SQLite
- Depois de
.load ./libhonker_ext, inicialize comSELECT honker_bootstrap();e use apenas funções SQL para fila, lock, rate limit, scheduler, stream e armazenamento de resultados - São fornecidas funções como
honker_claim_batch,honker_ack_batch,honker_sweep_expired,honker_lock_acquire,honker_rate_limit_try,honker_scheduler_tick,honker_stream_publish,honker_stream_read_sinceehonker_result_save - O binding de Python e a extensão compartilham
_honker_live,_honker_deade_honker_notifications, então um worker em Python pode buscar trabalhos inseridos por outra linguagem via extension - A compatibilidade de schema está fixada em
tests/test_extension_interop.py
- Depois de
Projeto
- Este repositório inclui em conjunto a SQLite loadable extension
honkere bindings para Python, Node, Rust, Go, Ruby, Bun e Elixir - Ele é voltado a aplicações que usam SQLite como armazenamento principal e foca em mover a lógica do pacote para uma extensão SQLite, para que possa ser usada de forma parecida em várias linguagens e frameworks
- Há três primitivas principais
notify(), um pub/sub efêmerostream(), um pub/sub durável com offsets por consumidorqueue(), uma fila de trabalho at-least-once
- Essas três primitivas são todas registradas por INSERT dentro da transação do chamador, de modo que o envio do trabalho e a escrita de negócio ou são commitados juntos, ou sofrem rollback juntos
- O objetivo é implementar um comportamento semelhante a
NOTIFY/LISTEN, sem polling no nível da aplicação, para obter tempos de resposta rápidos - Se você usar o arquivo SQLite existente como está, todo commit no banco despertará o worker, e a maioria dos triggers pode terminar lendo mensagens ou filas sem encontrar nada para processar
- Esse overtriggering é um trade-off intencional, escolhido para obter um comportamento mais próximo de push e tempos de resposta rápidos
Padrão recomendado: WAL
- As language bindings usam
journal_mode = WALpor padrão, o que oferece estrutura com leitores concorrentes e um único writer, batching eficiente defsynce a configuraçãowal_autocheckpoint = 10000 - Outros modos, como DELETE, TRUNCATE e MEMORY, também funcionam, e a detecção de commit é baseada no
PRAGMA data_version, que aumenta em todos os journal modes - O que se perde fora do modo WAL é apenas a característica de escrever durante leituras concorrentes; a corretude e o wake entre processos em si não dependem de WAL
- O sistema inteiro é composto por um único arquivo
.dbe, com WAL ativado, podem ser adicionados os sidecars.db-wale.db-shm - O claim é tratado com um único
UPDATE … RETURNINGvia partial index, e o ack com um únicoDELETE - Em qualquer journal mode, há apenas um writer por vez, e a vantagem de leitores concorrentes é fornecida pelo WAL
- O
PRAGMA data_versionaumenta a cada commit e checkpoint, então lida corretamente com situações como truncation do WAL, criação e remoção de arquivos de journal e reutilização com o mesmo tamanho - O SQLite não tem wire protocol, então push do servidor não é possível; o consumidor precisa iniciar a leitura por conta própria
- o sinal de wake é um incremento do contador
- depois, a consulta real é feita com
SELECT
- Como transações são baratas, jobs, events e notifications são gravados dentro do bloco
with db.transaction()já aberto pelo chamador, como no padrão outbox - Em vez de usar
stat(2)para ver tamanho emtimedo arquivo WAL, ou kernel watchers comoFSEvents,inotifyekqueue, usa-sePRAGMA data_versiondata_versioné um contador monotônico que o SQLite incrementa em qualquer commit de qualquer conexão- ele lida corretamente com truncation do WAL, clock skew e transações com rollback
- os kernel watchers do macOS perdem escritas do mesmo processo, e o
stat(2)baseado em(size, mtime)pode perder commits quando o WAL é truncado e depois cresce novamente até o mesmo tamanho - funciona da mesma forma em Linux, macOS e Windows, e o custo de CPU em resolução na faixa de 1 ms é muito baixo
- o custo por consulta é de cerca de 3,5 µs, ou aproximadamente 3,5 ms/s no total em 1 kHz
- O modelo de locks do SQLite pressupõe single machine, single writer, e dois servidores escrevendo no mesmo
.dbsobre NFS causam corrupção- nesses casos, é necessário shard por arquivo ou migrar para Postgres
Arquitetura
-
Caminho de wake
- Cada
Databasetem uma thread de polling de PRAGMA que consultadata_versiona cada 1 ms - Quando o contador muda, ela faz fan-out de um tick para o bounded channel de cada subscriber
- Cada subscriber executa
SELECT … WHERE id > last_seenusando partial index, retorna as novas linhas e depois volta a esperar - Mesmo com 100 subscribers, basta uma única poll thread
- Listeners ociosos não executam nenhuma consulta SQL
- O custo em idle é apenas uma consulta
PRAGMA data_versionpor banco de dados a cada 1 ms, e o número de listeners escala quase de graça graças à estrutura que usa leitura do contador do SQLite - O
SharedWalWatcherdehonker-coreé dono da poll thread e faz o fan-out para canais boundedSyncSender<()>por id de subscriber - Cada chamada de
db.wal_events()registra um subscriber, e o handle retornado cancela a inscrição automaticamente quando sofreDrop - Quando um listener sofre drop, a bridge thread recebe
rx.recv() -> Err, faz a limpeza e encerra
- Cada
-
Esquema da fila
_honker_livecontém linhas em estado pending e processing- O partial index tem a forma
(queue, priority DESC, run_at, id) WHERE state IN ('pending','processing') - O claim é feito com um único
UPDATE … RETURNINGpor meio desse índice - O ack é um único
DELETE - Linhas que excedem o limite de retry são movidas para
_honker_deade não voltam a ser varridas no caminho de claim - Graças ao partial index sobre
state, o hot path de claim é limitado pelo tamanho do working set, não pelo tamanho do histórico completo - Mesmo com 100k dead rows, a velocidade de claim permanece igual à de uma fila sem dead rows
-
Iterador de claim
async for job in q.claim(id)chama repetidamenteclaim_batch(id, 1)e entrega os trabalhos um por umJob.ack()é um únicoDELETEdentro da sua própria transação, e o valor de retorno seráTruese o claim ainda for válido ouFalsese a visibility window tiver passado e outro worker o tiver readquirido- Ele acorda com qualquer commit no banco de dados de qualquer processo, e um paranoia poll de 5 segundos é o único fallback
- Para trabalho em lote, é preciso usar diretamente
claim_batch(worker_id, n)equeue.ack_batch(ids, worker_id) - A biblioteca não esconde batches atrás do iterador, permitindo lidar com mais clareza com o custo de transação e o comportamento de visibilidade at-most-once
-
Acoplamento com transações
notify()é uma função SQL escalar registrada na conexão de escrita- Ela faz
INSERTem_honker_notificationssob a transação aberta do chamador queue.enqueue(…, tx=tx)estream.publish(…, tx=tx)funcionam da mesma forma- Se ocorrer rollback, job, event e notification também desaparecem juntos
- Isso é um padrão transactional outbox nativo, permitindo tratar a escrita de negócio e o enqueue de side effects juntos, sem instalar biblioteca separada
- Não há tabela de dispatch nem processo dispatcher separado; a própria linha do side effect é a linha commitada, e qualquer processo monitorando o banco pode capturá-la em cerca de 1 ms
-
Over-triggering mais rápido que polling
- Mudanças em
data_versionacordam todos os subscribers daqueleDatabase, sem acordar seletivamente apenas o canal que recebeu commit - O custo de acordar por engano é de apenas um
SELECTindexado, na faixa de microssegundos - Em contrapartida, deixar de acordar quem deveria ser acordado leva a um bug silencioso de corretude
- A filtragem por canal é tratada no caminho do
SELECT, não na etapa de notificação do trigger - O SQLite também consegue lidar com eficiência com o padrão de executar muitas consultas pequenas
- Mudanças em
-
Política de retenção
- Trabalhos da fila permanecem até receberem ack e, ao excederem o limite de retry, são movidos para
_honker_dead - Eventos de stream são mantidos, e cada consumidor nomeado rastreia seu próprio offset
notifyé fire-and-forget e não tem limpeza automática- A política de retenção é escolhida pelo chamador para cada primitive, e é preciso chamar
db.prune_notifications(older_than_s=…, max_keep=…)diretamente - A ideia é expor a política de retention no código do chamador, em vez de escondê-la atrás dos padrões da biblioteca
- Trabalhos da fila permanecem até receberem ack e, ao excederem o limite de retry, são movidos para
Recuperação de falhas
- o rollback remove jobs, events e notifications junto com as escritas de negócio, seguindo as propriedades ACID do SQLite
- é seguro mesmo se ocorrer
SIGKILLdurante a transação, e no próximo open o rollback de commit atômico do SQLite não deixa stale state- o uso de WAL ou rollback journal depende do journal mode
- a validação foi feita em
tests/test_crash_recovery.py, encerrando o subprocess antes do COMMIT e depois verificandoPRAGMA integrity_check == 'ok'e um novo fluxo de notify
- se um worker morrer durante o processamento, outro worker faz o claim novamente após
visibility_timeout_s- o padrão é 300 segundos
attemptsé incrementado- se ultrapassar o padrão de 3 em
max_attempts, a linha é movida para_honker_dead
- um listener que estava offline durante o prune perde os eventos removidos; se for necessário durable replay, deve-se usar
db.stream()armazenando offsets por consumidor
Integração com frameworks web
- não fornece plugins para frameworks; como a API é pequena, a proposta é integrar com algumas linhas de glue code
- no FastAPI, há um exemplo de iniciar o loop do worker no startup e, durante o tratamento da request, executar juntos a escrita de negócio e o enqueue da fila dentro da transação
- um endpoint SSE pode ser montado em cerca de 30 linhas no formato
async def stream(...): yield f"data: ...\n\n", sobredb.listen(channel)oudb.stream(name).subscribe(...) - em Django e Flask, recomenda-se uma configuração em que o worker roda como um processo CLI separado, em um padrão semelhante ao Celery ou RQ
Uso com ORM
- ao carregar
libhonker_extna conexão do ORM e chamar funções SQL dentro da própria transação do ORM, o enqueue é commitado atomicamente com a escrita de negócio - no exemplo com SQLAlchemy, a extensão é carregada no evento de connect e
SELECT honker_bootstrap()é executado; depois, dentro da transaçãos.begin(), são chamados juntos o INSERT do model eSELECT honker_enqueue(...) - o worker roda em um processo separado usando
honker.open("app.db"), e o commit watcher desperta com commits de qualquer conexão para o mesmo arquivo - o guia Using with an ORM inclui integrações com Django, SQLModel, Drizzle, Kysely, sqlx, GORM, ActiveRecord e Ecto, além do padrão de wrapper
TypedQueue[T]para SQLModel/Pydantic e ressalvas sobre o Prisma
Desempenho
- informa que consegue processar milhares de mensagens por segundo em notebooks modernos
- a latência de wake entre processos é limitada pela cadência de polling de 1 ms, com mediana de cerca de 1~2 ms em M-series
- as medições em hardware real podem ser feitas com
bench/wake_latency_bench.pyebench/real_bench.py
Configuração de desenvolvimento
-
Layout do repositório
honker-core/:rlibem Rust compartilhada por todos os bindings, incluída in-tree e também publicada no crates.iohonker-extension/:cdylibpara extensão loadable do SQLite, incluída in-tree e também publicada no crates.iopackages/honker/: pacote Python comcdylibem PyO3 e Queue, Stream, Outbox e Schedulerpackages/honker-node/: binding para Node.js e submódulo gitpackages/honker-rs/: wrapper ergonômico para Rust e submódulo gitpackages/honker-go/: binding para Go e submódulo gitpackages/honker-ruby/: binding para Ruby e submódulo gitpackages/honker-bun/: binding para Bun e submódulo gitpackages/honker-ex/: binding para Elixir e submódulo gitpackages/honker-cpp/: binding para C++ e submódulo gittests/: diretório de testes de integração cross-packagebench/: diretório de benchmarkssite/: sitehonker.dev, baseado em Astro, e submódulo git- cada repositório de binding é publicado separadamente em PyPI, npm, crates.io, Hex, RubyGems etc., enquanto a base comum
honker-coreehonker-extensioné incluída diretamente neste repositório - ao fazer clone, é necessário usar
git clone --recursiveougit submodule update --init --recursive
Testes e cobertura
make testexecuta por padrão os testes de Rust, Python e Node, e leva cerca de 10 segundos no caminho rápidomake test-python-slowinclui testes de soak e cron em tempo real, e leva cerca de 2 minutosmake test-allexecuta a suíte completa, incluindo as marcas lentasmake buildexecuta omaturin developdo PyO3 e o build da extensão loadable- os benchmarks podem ser executados com
python bench/wake_latency_bench.py --samples 500,python bench/real_bench.py --workers 4 --enqueuers 2 --seconds 15,python bench/ext_bench.py - para instalar as ferramentas de cobertura, usa-se
make install-coverage-deps, que instalacoverage.pyecargo-llvm-cov make coveragegera dois relatórios HTML emcoverage/, emake coverage-pythongera o relatório do caminho Python, enquantomake coverage-rustgera o relatório com base nos testes unitários Rust dehonker-core- a cobertura Python é informada como cerca de 92% em
packages/honker/ - a cobertura Rust reflete apenas
cargo test; vários caminhos emhonker_ops.rssão executados apenas pela suíte de testes Python e, por isso, não aparecem no relatório Rust - a combinação de cobertura cross-language por meio da mesclagem de dados de perfil LLVM através da fronteira do PyO3 é difícil e ainda foi deixada para depois
Licença
- usa a licença Apache 2.0
- mais detalhes em LICENSE
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