2 pontos por GN⁺ 6 일 전 | 1 comentários | Compartilhar no WhatsApp
  • 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() e queue() 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/LISTEN no 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_version fica 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
    • enqueue retorna um id, o worker armazena o valor de retorno e quem chamou pode esperar o resultado com queue.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 como db.queue("emails") para enfileirar e consumir trabalhos
    • Se você executar o INSERT do pedido e emails.enqueue(..., tx=tx) juntos dentro do bloco with 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 com job.ack() em caso de sucesso ou job.retry(delay_s=60, error=str(e)) em caso de falha
    • claim() é um iterador assíncrono e internamente chama claim_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) e queue.ack_batch(ids, worker_id), e a visibility padrão é de 300 segundos
  • Tasks em Python

    • Com o decorador @emails.task(retries=3, timeout_s=30), a chamada da função passa a virar diretamente um enfileiramento, retornando TaskResult
    • Quem chama pode usar como em send_email("alice@example.com", "Hi") e esperar o resultado da execução do worker com r.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
  • Streams em Python

    • db.stream("user-events") fornece pub/sub durável, e é possível executar o UPDATE de negócio e stream.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= e save_every_s=; se ambos forem 0, o salvamento automático é desativado e stream.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 com tx.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
  • Node.js

    • O binding de Node também usa o mesmo padrão com open('app.db'), db.transaction(), tx.notify(...) e db.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
  • Extensão SQLite

    • Depois de .load ./libhonker_ext, inicialize com SELECT 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_since e honker_result_save
    • O binding de Python e a extensão compartilham _honker_live, _honker_dead e _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

Projeto

  • Este repositório inclui em conjunto a SQLite loadable extension honker e 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êmero
    • stream(), um pub/sub durável com offsets por consumidor
    • queue(), 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 = WAL por padrão, o que oferece estrutura com leitores concorrentes e um único writer, batching eficiente de fsync e a configuração wal_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 .db e, com WAL ativado, podem ser adicionados os sidecars .db-wal e .db-shm
  • O claim é tratado com um único UPDATE … RETURNING via partial index, e o ack com um único DELETE
  • Em qualquer journal mode, há apenas um writer por vez, e a vantagem de leitores concorrentes é fornecida pelo WAL
  • O PRAGMA data_version aumenta 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 e mtime do arquivo WAL, ou kernel watchers como FSEvents, inotify e kqueue, usa-se PRAGMA data_version
    • data_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 .db sobre NFS causam corrupção
    • nesses casos, é necessário shard por arquivo ou migrar para Postgres

Arquitetura

  • Caminho de wake

    • Cada Database tem uma thread de polling de PRAGMA que consulta data_version a 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_seen usando 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_version por 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 SharedWalWatcher de honker-core é dono da poll thread e faz o fan-out para canais bounded SyncSender<()> por id de subscriber
    • Cada chamada de db.wal_events() registra um subscriber, e o handle retornado cancela a inscrição automaticamente quando sofre Drop
    • Quando um listener sofre drop, a bridge thread recebe rx.recv() -> Err, faz a limpeza e encerra
  • Esquema da fila

    • _honker_live conté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 … RETURNING por meio desse índice
    • O ack é um único DELETE
    • Linhas que excedem o limite de retry são movidas para _honker_dead e 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 repetidamente claim_batch(id, 1) e entrega os trabalhos um por um
    • Job.ack() é um único DELETE dentro da sua própria transação, e o valor de retorno será True se o claim ainda for válido ou False se 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) e queue.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 INSERT em _honker_notifications sob a transação aberta do chamador
    • queue.enqueue(…, tx=tx) e stream.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_version acordam todos os subscribers daquele Database, sem acordar seletivamente apenas o canal que recebeu commit
    • O custo de acordar por engano é de apenas um SELECT indexado, 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
  • 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

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 SIGKILL durante 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 verificando PRAGMA 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", sobre db.listen(channel) ou db.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_ext na 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ção s.begin(), são chamados juntos o INSERT do model e SELECT 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.py e bench/real_bench.py

Configuração de desenvolvimento

  • Layout do repositório

    • honker-core/: rlib em Rust compartilhada por todos os bindings, incluída in-tree e também publicada no crates.io
    • honker-extension/: cdylib para extensão loadable do SQLite, incluída in-tree e também publicada no crates.io
    • packages/honker/: pacote Python com cdylib em PyO3 e Queue, Stream, Outbox e Scheduler
    • packages/honker-node/: binding para Node.js e submódulo git
    • packages/honker-rs/: wrapper ergonômico para Rust e submódulo git
    • packages/honker-go/: binding para Go e submódulo git
    • packages/honker-ruby/: binding para Ruby e submódulo git
    • packages/honker-bun/: binding para Bun e submódulo git
    • packages/honker-ex/: binding para Elixir e submódulo git
    • packages/honker-cpp/: binding para C++ e submódulo git
    • tests/: diretório de testes de integração cross-package
    • bench/: diretório de benchmarks
    • site/: site honker.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-core e honker-extension é incluída diretamente neste repositório
    • ao fazer clone, é necessário usar git clone --recursive ou git submodule update --init --recursive

Testes e cobertura

  • make test executa por padrão os testes de Rust, Python e Node, e leva cerca de 10 segundos no caminho rápido
  • make test-python-slow inclui testes de soak e cron em tempo real, e leva cerca de 2 minutos
  • make test-all executa a suíte completa, incluindo as marcas lentas
  • make build executa o maturin develop do 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 instala coverage.py e cargo-llvm-cov
  • make coverage gera dois relatórios HTML em coverage/, e make coverage-python gera o relatório do caminho Python, enquanto make coverage-rust gera o relatório com base nos testes unitários Rust de honker-core
  • a cobertura Python é informada como cerca de 92% em packages/honker/
  • a cobertura Rust reflete apenas cargo test; vários caminhos em honker_ops.rs sã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

 
GN⁺ 6 일 전
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 SQLite
    També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 .db do app existente, então podem ser commitados atomicamente com as escritas de negócio, e se houver rollback os dois desaparecem juntos
    Originalmente era litenotify/joblite, mas eu tinha comprado honker.dev de 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 valendo

    • Isso parece voltado principalmente para linguagens em que só é fácil lidar com concorrência baseada em processos
      Em 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
    • Descobri com isso que até fazer stat() a cada 1 ms é surpreendentemente barato
      No 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
    • Posso estar deixando passar algo, mas me parece que PRAGMA data_version seria melhor que stat(2)
      https://sqlite.org/pragma.html#pragma_data_version
      Na API C também existe o mais direto SQLITE_FCNTL_DATA_VERSION
      https://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
    • Bem legal. Eu também já fiz metade de algo parecido
      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
    • Talvez ficasse ainda melhor armazenando também o estado do subscriber
      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 fazer Events INNER JOIN Subscribers e acordar só os subscribers que realmente combinam
  • Valeu 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_version a cada 1 ms, stat a cada 100 ms e tratamento de reconexão em caso de erro

    1. Passei a usar PRAGMA data_version a cada 1 ms para substituir a detecção anterior de mudança de size/mtime baseada em stat. 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 imaginava
      Nos testes, o SQLITE_FCNTL_DATA_VERSION da 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 tradeoff
    2. Se a consulta de data_version falhar, 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 subscribers
    3. A cada 100 ms, uso stat para 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. O data_version segue o fd aberto, então mesmo se o arquivo mudar ele continua vendo o inode original, e por isso não detecta isso
      Com 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

    • Boa propaganda, e bem pertinente ao tópico
  • Fico pensando se não daria para observar mudanças no WAL com inotify ou algum wrapper multiplataforma, sem polling

    • Isso quebra no multiplataforma. Especialmente no Mac, às vezes engole as notificações silenciosamente, então é difícil confiar
      stat simplesmente funciona em todo lugar
  • O 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 perdidos

    • Acho que a atomicidade é praticamente tudo aqui
      Já 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
    • O arquivo WAL continua lá e só é truncado, então isso acaba sendo capturado como update por si só
      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

    • A expressão proliferação de pequenas coisas descreve perfeitamente o agrupamento que meus hábitos de side project acabaram criando
      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 stat pareça, no fim parece ser o que realmente funciona em qualquer lugar
    També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

    • Este comentário está completamente errado
      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
    • Isso realmente precisa ser testado
      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

    • O gargalo está mais na escrita e no fluxo de claim/ack
      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 em PRAGMA, é muito barata. Em outro comentário também foi dito que stat(2) fica na casa de ~1 µs
  • Projeto 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