Honker - Une extension qui implémente NOTIFY/LISTEN de Postgres dans SQLite
(github.com/russellromney)- Intègre une file durable, des streams, du pub/sub et un scheduler dans un seul fichier SQLite, permettant de traiter des tâches asynchrones sans broker séparé comme Redis ou Celery
- Atteint une réactivité inter-processus de l’ordre de quelques millisecondes en pollant
PRAGMA data_versiontoutes les 1 ms, sans polling au niveau applicatif ni démon notify(),stream()etqueue()sont tous écrits dans la transaction de l’appelant, puis soit commités avec les écritures métier soit rollbackés ensemble, ce qui réduit les problèmes de dual-write- La file de tâches inclut retries, priorité, exécution différée, dead-letter, scheduler, named lock et rate limiting, tandis que les streams prennent en charge une livraison at-least-once en stockant les offsets par consommateur
- Dans les environnements qui utilisent SQLite comme stockage principal, il devient possible d’exploiter l’application et le traitement asynchrone avec un seul fichier de base de données, ce qui réduit la complexité opérationnelle
- Fournit trois primitives clés
- queue() : file de tâches at-least-once — retries, priorité, tâches différées, dead-letter, visibility timeout
- stream() : pub/sub durable — suivi des offsets par consommateur, replay at-least-once
- notify() : pub/sub éphémère — fire-and-forget, sans replay d’historique
- Un décorateur
@queue.task()de style Huey permet de transformer une fonction en tâche de file, avec prise en charge des tâches périodiques basées surcrontab()et d’un scheduler avec élection de leader - Le schéma de file applique un partial index à la table
_honker_live; le claim se fait avec un seulUPDATE … RETURNINGet l’ack avec un seulDELETE, garantissant des performances stables quel que soit le nombre de lignes mortes - En tant qu’extension SQLite chargeable (
libhonker_ext), il permet à tous les clients SQLite 3.9+ d’accéder aux mêmes tables — un worker Python peut claim des tâches poussées depuis d’autres langages - Fournit des guides d’intégration avec les principaux ORM comme SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord et Ecto
- Même une transaction interrompue par un SIGKILL reste sûre grâce aux garanties ACID de SQLite, et en cas de crash d’un worker, le reclaming se fait automatiquement après expiration du visibility timeout
- Fournit des bindings pour 8 langages : Python, Node.js, Rust, Go, Ruby, Bun, Elixir et C++, chacun publié séparément sur PyPI, npm, crates.io, Hex et RubyGems
- Implémenté en Rust (
honker-core+honker-extension) - Licence Apache 2.0
1 commentaires
Avis sur Hacker News
C’est moi qui ai construit ça. Honker ajoute un NOTIFY/LISTEN inter-processus à SQLite, ce qui permet une diffusion d’événements de type push avec une latence à un chiffre en ms, sans daemon ni broker, uniquement avec le fichier SQLite existant
Comme SQLite n’a pas de serveur comme Postgres, l’idée clé a été de déplacer la source de polling vers un simple
stat(2)léger sur le fichier WAL au lieu d’interroger périodiquement la base. SQLite reste efficace même avec beaucoup de petites requêtes (https://www.sqlite.org/np1queryprob.html), donc ce n’est peut-être pas une révolution énorme, mais le fait qu’il suffise de surveiller le WAL et d’appeler des fonctions SQLite est intéressant, car cela le rend indépendant du langageJ’y ai aussi ajouté un pub/sub éphémère, une durable work queue avec retries et dead-letter, ainsi qu’un event stream avec offsets par consommateur. Dans les trois cas, tout est stocké sous forme de lignes dans le fichier
.dbde l’application existante, donc on peut committer atomiquement avec les écritures métier, et si on rollback, les deux disparaissent ensembleÀ l’origine, ça s’appelait litenotify/joblite, puis j’ai réalisé que j’avais acheté
honker.devpour plaisanter, et comme Oban, pg-boss, Huey, RabbitMQ, Celery et Sidekiq ont tous des noms un peu ridicules, j’ai gardé celui-là. J’espère que ce sera utile, ou au moins drôle, et l’avertissement « logiciel alpha » reste pleinement valableDans des écosystèmes comme Java/Go/Clojure/C#, comme SQLite reste de toute façon single writer, il paraît plus simple et plus propre que l’application gère ce writer et utilise une file concurrente au niveau du langage pour savoir quelles écritures ont eu lieu et ne réveiller que les threads concernés
Cela dit, c’est amusant de voir le WAL utilisé de manière aussi créative, et dans des langages comme Python/JS/TS/Ruby où la concurrence par processus est courante, ça semble plutôt bien coller comme mécanisme de notification
stat()toutes les 1 ms coûte étonnamment peuSur mon matériel, l’appel prend moins de 1μs, donc avec ce niveau de polling, l’utilisation CPU reste sous les 0,1 %
PRAGMA data_versionne serait pas mieux questat(2)https://sqlite.org/pragma.html#pragma_data_version
Et côté API C, il y a aussi le plus direct
SQLITE_FCNTL_DATA_VERSIONhttps://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
Je me demande si on pourrait aussi l’utiliser comme Kafka léger, c’est-à-dire comme flux de messages persistant. Je me demande si on pourrait avoir une sémantique du type rejouer tous les messages passés + temps réel à partir d’un certain timestamp pour un topic donné
On pourrait probablement l’imiter par polling comme pour du pub/sub, mais comme tu l’as dit, ce ne serait sans doute pas optimal
Si on gardait la position de lecture, le nom de la queue, les filtres, etc., alors au lieu de réveiller tous les threads d’abonnement à chaque changement détecté par
stat(2)pour qu’ils fassent chacun un SELECT avec N=1, le thread de polling pourrait faire unEvents INNER JOIN Subscriberset ne réveiller que les abonnés réellement concernésMerci pour les retours. J’ai ouvert une PR qui intègre les suggestions
https://github.com/russellromney/honker/pulls/1
C’est maintenant une structure de polling à 3 niveaux :
PRAGMA data_versiontoutes les 1 ms,stattoutes les 100 ms, et reconnexion en cas d’erreurPRAGMA data_versiontoutes les 1 ms à la place de la détection précédente des changements de taille/mtime basée surstat. Comme c’est le commit counter interne de SQLite, il est monotone, ne dépend pas d’un éventuel clock skew, et gère correctement les WAL truncations comme les rollbacks. C’est une requête non bloquante d’environ 3µs, et je l’ai adoptée pour la justesse, pas pour les performances. En fait, c’est même légèrement plus lent. Le risque lié à la troncature s’est révélé plus réaliste que je ne le pensaisAprès test,
SQLITE_FCNTL_DATA_VERSIONde l’API C ne fonctionnait pas entre connexions. Pour l’instant, j’assume donc toujours le coût du passage par la couche VFS, et j’accepte explicitement ce compromisdata_versionéchoue, je tente une reconnexion en supposant un problème temporaire disque, un hiccup NFS ou une corruption de connexion, et par précaution je réveille aussi les abonnésstatle(dev, ino)courant à la valeur observée au démarrage afin de détecter un remplacement de fichier. Ça couvre des cas comme un atomic rename, une restauration litestream ou un remount de volume.data_version, lui, suit le fd déjà ouvert, donc si le fichier change, il continue à regarder l’inode d’origine et ne peut pas le détecterAu final, Honker s’est amélioré et moi aussi j’ai beaucoup appris
Petite autopromo discrète : dans PostgreSQL 19, LISTEN/NOTIFY a été optimisé pour bien mieux passer à l’échelle dans les scénarios de selective signaling
Le patch vise les cas où de nombreux backends écoutent des channels différents
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9
Je me demande s’il ne serait pas possible de surveiller les changements du WAL avec inotify ou un wrapper multiplateforme, au lieu de faire du polling
statfonctionne simplement partoutCe qui le rend plus attrayant qu’un IPC séparé, c’est le commit atomique avec les données métier
Avec une messagerie externe, on se retrouve toujours avec le problème du type « la notification est partie mais la transaction a rollback », et ça devient vite sale
Je me demande quand même pour le WAL checkpoint : quand SQLite retronque le WAL à 0, je ne sais pas si un polling par
stat()le gère correctement. J’ai l’impression qu’il pourrait y avoir une fenêtre où des événements se perdentJ’ai déjà galéré avec une combinaison Postgres+SQS où un trigger envoyait l’enqueue avant que le commit soit visible sur une autre connexion. On a ajouté de la logique de retry, puis du polling côté worker, puis on a fini par remettre l’enqueue dans la transaction ; mais à ce stade, on était juste en train de reconstruire ce que fait Honker avec plus de pièces mobiles
Les bugs du type « la notification est partie mais la ligne n’est pas encore commitée » sont généralement silencieux et dépendent du timing, donc ils sont vraiment pénibles à traquer
Cela dit, je n’ai pas encore de tests sur ce point, donc il faut que je vérifie davantage. C’est une bonne remarque, je vais m’en occuper
Merci
Il y a de plus en plus de petites applis basées sur SQLite, et la plupart ont besoin d’une queue et d’un scheduler
J’en ai déjà bricolé quelques-uns moi-même, mais l’élégance des solutions de la famille Postgres m’a toujours manqué
Celui-ci, je vais clairement l’essayer très vite
Si tu rencontres un problème, n’hésite pas à laisser une PR ou une issue sur le repo
J’avoue que ça donne envie d’utiliser kqueue/FSEvents ici, mais il me semble que Darwin perd les notifications venant du même processus
Si le publisher et le listener sont dans le même processus, il arrive que le listener ne soit pas réveillé du tout, ce qui est assez pénible à déboguer. Le polling par
stat, même s’il est moche, a au final l’air d’être ce qui fonctionne réellement partoutJe me demande aussi si, lors d’un WAL checkpoint, quand le fichier rétrécit à nouveau, cela déclenche bien un wakeup, ou si le poller filtre les diminutions de taille
Les événements VNODE de kqueue sont délivrés dès lors que le processus a le droit d’accéder au fichier, et il n’existe pas de filtre qui les supprime parce que cela vient du même processus
Je vais vérifier et je reviendrai avec le résultat
Très sympa. Je me demande si, sous charge, le goulot d’étranglement est surtout le débit d’écriture SQLite, ou bien la couche de notification WAL
Ça varie aussi beaucoup selon le journal mode et le synchronous mode
La notification, que ce soit via l’ancienne approche
stat(2)ou la nouvelle basée surPRAGMA, coûte très peu. Comme dit dans un autre commentaire,stat(2)tourne autour de 1µsBeau projet. Moi aussi je construis quelque chose qui pousse SQLite bien au-delà de ses usages habituels
C’est encourageant de voir davantage de gens explorer jusqu’où SQLite peut réellement aller
Je me demande s’il est possible de l’intégrer même quand on utilise SQLAlchemy
Tel que c’est présenté pour l’instant, on dirait qu’il veut créer lui-même sa propre connexion DB