2 Punkte von GN⁺ 5 일 전 | 1 Kommentare | Auf WhatsApp teilen
  • Integriert dauerhafte Queue, Streams, Pub/Sub und Scheduler in einer einzigen SQLite-Datei, sodass asynchrone Auftragsverarbeitung ohne separate Broker wie Redis oder Celery möglich ist
  • Pollt PRAGMA data_version im 1-ms-Intervall und erreicht dadurch Reaktionszeiten zwischen Prozessen im einstelligen Millisekundenbereich, ganz ohne Polling auf Anwendungsebene oder Daemon
  • notify(), stream() und queue() werden alle innerhalb der Transaktion des Aufrufers geschrieben und entweder zusammen mit den Business-Schreibvorgängen committet oder gemeinsam zurückgerollt, was Dual-Write-Probleme reduziert
  • Die Job-Queue umfasst Retries, Prioritäten, verzögerte Ausführung, Dead Letter, Scheduler, Named Lock und Rate Limiting; Streams unterstützen at-least-once-Zustellung, indem sie Offsets pro Consumer speichern
  • In Umgebungen, in denen SQLite als primärer Datenspeicher verwendet wird, lassen sich Anwendung und asynchrone Verarbeitung in einer einzigen Datenbankdatei bündeln, was die Betriebs-Komplexität senkt
  • Bietet drei zentrale Primitive
    • queue(): at-least-once-Job-Queue — Retries, Prioritäten, verzögerte Jobs, Dead Letter, Visibility Timeout
    • stream(): dauerhafte Pub/Sub — Offset-Tracking pro Consumer, at-least-once-Replay
    • notify(): flüchtige Pub/Sub — fire-and-forget, kein History-Replay
  • Mit dem Huey-Stil-@queue.task()-Dekorator lassen sich Funktionen in Queue-Jobs umwandeln; unterstützt außerdem periodische Jobs auf Basis von crontab() sowie einen Scheduler mit Leader Election
  • Das Queue-Schema verwendet einen Partial Index auf der Tabelle _honker_live; Claiming erfolgt über genau ein UPDATE … RETURNING, Acking über genau ein DELETE, wodurch eine konstante Performance unabhängig von der Anzahl toter Zeilen erreicht wird
  • Als ladbare SQLite-Erweiterung (libhonker_ext) ermöglicht es allen SQLite-3.9+-Clients den Zugriff auf dieselben Tabellen — ein Python-Worker kann Jobs claimen, die aus anderen Sprachen eingestellt wurden
  • Bietet Integrationsleitfäden für SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord und Ecto sowie weitere wichtige ORMs
  • Selbst Transaktionen während eines SIGKILL bleiben dank SQLite-ACID sicher; bei Worker-Abstürzen erfolgt nach Ablauf des Visibility Timeout automatisch ein Re-Claim
  • Bietet Bindings für 8 Sprachen: Python, Node.js, Rust, Go, Ruby, Bun, Elixir und C++, jeweils separat veröffentlicht über PyPI, npm, crates.io, Hex und RubyGems
  • Implementiert in Rust (honker-core + honker-extension)
  • Apache-2.0-Lizenz

1 Kommentare

 
GN⁺ 5 일 전
Hacker-News-Kommentare
  • Ich habe das selbst gebaut. Honker bringt prozessübergreifendes NOTIFY/LISTEN zu SQLite und ermöglicht Push-artige Event-Zustellung mit Latenzen im einstelligen Millisekundenbereich – nur mit der vorhandenen SQLite-Datei, ganz ohne Daemon oder Broker.
    SQLite hat im Gegensatz zu Postgres keinen Server; der entscheidende Trick war also, die Polling-Quelle von periodischen Queries auf ein leichtgewichtiges stat(2) der WAL-Datei zu verlagern. SQLite ist auch bei vielen kleinen Queries effizient (https://www.sqlite.org/np1queryprob.html), daher ist das nicht unbedingt ein riesiges Upgrade, aber interessant ist, dass es sprachunabhängig funktioniert, solange man nur die WAL überwacht und SQLite-Funktionen aufruft.
    Darauf aufbauend gibt es außerdem ephemeres Pub/Sub, eine durable Work Queue mit Retries und Dead Letter sowie einen Event-Stream mit Offsets pro Consumer. Alle drei sind einfach Zeilen in der .db-Datei der bestehenden App und können daher atomar committet werden zusammen mit den Business-Writes; bei einem Rollback verschwinden beide gemeinsam.
    Ursprünglich hieß das litenotify/joblite, aber ich hatte mir aus Spaß honker.dev gesichert, und als mir dann auffiel, dass Namen wie Oban, pg-boss, Huey, RabbitMQ, Celery und Sidekiq auch alle etwas albern sind, bin ich einfach dabei geblieben. Hoffentlich ist es nützlich oder wenigstens lustig; die Warnung, dass es Alpha-Software ist, gilt weiterhin.

    • Das scheint vor allem für Sprachen gedacht zu sein, in denen sich prozessbasierte Parallelität leichter handhaben lässt.
      In Java/Go/Clojure/C# etwa ist SQLite ohnehin ein Single Writer, daher wirkt es einfacher und sauberer, wenn die Anwendung diesen Writer selbst verwaltet und über eine sprachinterne Concurrent Queue mitbekommt, welche Writes passiert sind, um nur die relevanten Threads aufzuwecken.
      Trotzdem ist es spannend, die WAL auf diese kreative Weise zu nutzen, und in Sprachen wie Python/JS/TS/Ruby, in denen prozessbasierte Parallelität üblich ist, scheint das als Notify-Mechanismus ziemlich gut zu passen.
    • Ich habe diesmal gelernt, dass selbst stat() alle 1 ms überraschend günstig ist.
      Auf meiner Hardware dauert ein Aufruf weniger als 1 μs, daher bleibt die CPU-Auslastung selbst bei diesem Polling unter 0,1 %.
    • Vielleicht übersehe ich etwas, aber wäre PRAGMA data_version nicht besser als stat(2)?
      https://sqlite.org/pragma.html#pragma_data_version
      In der C-API gibt es außerdem das direktere SQLITE_FCNTL_DATA_VERSION.
      https://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
    • Ziemlich cool. Ich habe selbst mal etwas Ähnliches halb gebaut.
      Ich frage mich, ob man das auch als leichtgewichtiges Kafka für persistente Nachrichtenströme nutzen kann. Also mit Semantik wie: für ein bestimmtes Topic alle vergangenen und Echtzeit-Nachrichten ab einem bestimmten Timestamp replayen.
      Man könnte das wie bei Pub/Sub wohl auch mit Polling nachbilden, aber wie du sagst, vermutlich nicht optimal.
    • Es könnte noch besser werden, wenn man auch den Subscriber-Status speichert.
      Wenn man Leseposition, Queue-Namen, Filter usw. speichert, könnte der Polling-Thread bei jeder stat(2)-Änderung statt aller Subscription-Threads, die dann jeweils ein N=1-SELECT machen, nur die tatsächlich passenden Subscriber aufwecken – etwa über Events INNER JOIN Subscribers.
  • Danke für das Feedback. Ich habe einen PR mit den Vorschlägen eingereicht.
    https://github.com/russellromney/honker/pulls/1
    Jetzt gibt es eine dreistufige Polling-Struktur: PRAGMA data_version alle 1 ms, stat alle 100 ms und Reconnect-Handling bei Fehlern.

    1. Ich habe das bisherige stat-basierte Erkennen von Größen-/mtime-Änderungen durch PRAGMA data_version alle 1 ms ersetzt. Das ist ein interner Commit-Counter von SQLite, also monoton, unempfindlich gegen Clock Skew und behandelt auch WAL-Truncation oder Rollback korrekt. Es ist eine nicht blockierende Query von etwa 3 µs, und ich habe das nicht wegen der Performance geändert, sondern wegen der Korrektheit. Tatsächlich ist es sogar minimal langsamer. Das Truncation-Risiko war realer als gedacht.
      Beim Testen zeigte sich, dass die C-API SQLITE_FCNTL_DATA_VERSION zwischen verschiedenen Connections nicht funktionierte. Daher zahlen wir derzeit weiterhin die Kosten des VFS-Layers und akzeptieren diesen Trade-off bewusst.
    2. Wenn die data_version-Query fehlschlägt, wird ein Reconnect versucht, um Fälle wie temporäre Disk-Fehler, NFS-Hiccups oder Connection-Korruption abzufangen; vorsorglich werden dabei auch Subscriber aufgeweckt.
    3. Alle 100 ms wird per stat (dev, ino) mit den Werten beim Start verglichen, um Dateiaustausch zu erkennen. Das betrifft Dinge wie atomisches Rename, litestream-Restore oder Volume-Remounts. data_version folgt dem offenen Dateideskriptor und würde daher weiter auf das ursprüngliche Inode zeigen, selbst wenn die Datei ersetzt wurde.
      Dadurch ist Honker besser geworden, und ich habe selbst viel dabei gelernt.
  • Nur als kleine Eigenwerbung: Im kommenden PostgreSQL 19 wurde LISTEN/NOTIFY so optimiert, dass es bei selektivem Signaling deutlich besser skaliert.
    Der Patch zielt auf Fälle ab, in denen viele Backends auf unterschiedliche Channels lauschen.
    https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9

    • Gute Eigenwerbung, und sie passt auch sehr gut zum Thema.
  • Könnte man nicht statt Polling inotify oder einen plattformübergreifenden Wrapper verwenden, um WAL-Änderungen zu überwachen?

    • Dann geht die Plattformunabhängigkeit verloren. Vor allem auf dem Mac wird so etwas mitunter still verschluckt, weshalb es schwer zuverlässig zu nutzen ist.
      stat funktioniert einfach überall.
  • Attraktiver als separate IPC ist hier, dass die Sache atomar mit den Business-Daten committet wird.
    Bei externer Nachrichtenübermittlung hat man immer das Problem „Benachrichtigung wurde gesendet, aber die Transaktion wurde zurückgerollt“, und das wird schnell unsauber.
    Was ich mich frage, ist das WAL-Checkpointing. Wenn SQLite die WAL wieder auf 0 truncatet, weiß ich nicht, ob stat()-Polling das korrekt behandelt. Es fühlt sich an, als könnte es dabei ein Fenster geben, in dem Events verloren gehen.

    • Die Atomarität ist praktisch alles.
      Ich hatte früher mit einer Postgres+SQS-Kombination Probleme, weil ein Trigger das Enqueueing verschickte, bevor der Commit auf einer anderen Connection sichtbar war. Dann kamen Retry-Logik dazu, Polling auf Worker-Seite, und am Ende haben wir das Enqueueing in die Transaktion verlegt – und damit im Grunde nur nachgebaut, was Honker macht, nur mit mehr beweglichen Teilen.
      Solche Bugs nach dem Muster „Benachrichtigung wurde gesendet, aber die Zeile ist noch nicht committet“ sind meist still, timingabhängig und wirklich unerquicklich zu debuggen.
    • Die WAL-Datei bleibt erhalten und wird nur truncatet, daher sollte das an sich als Update erkannt werden.
      Allerdings gibt es für diesen Teil noch keine Tests, deshalb muss ich das noch verifizieren. Guter Punkt, ich schaue mir das an.
  • Danke
    Es gibt immer mehr kleine Apps auf SQLite-Basis, und die meisten brauchen Queues und Scheduler.
    Ich habe selbst schon ein paar Dinge ausprobiert, aber mir fehlte immer die Eleganz der Postgres-basierten Lösungen.
    Das hier werde ich sehr bald ausprobieren.

    • Die Formulierung kleine Vermehrung beschreibt den Schwarm an Side Projects, den meine Gewohnheiten erzeugt haben, erstaunlich gut.
      Wenn du auf Probleme stößt, wäre ein PR oder ein Issue im Repo super.
  • Hier würde man gern kqueue/FSEvents verwenden, aber meines Wissens verwirft Darwin Benachrichtigungen aus demselben Prozess.
    Wenn Publisher und Listener im selben Prozess sind, wacht der Listener manchmal gar nicht auf, und das ist ziemlich unerquicklich zu verfolgen. stat-Polling sieht zwar hässlich aus, scheint am Ende aber das zu sein, was überall tatsächlich funktioniert.
    Ich frage mich auch, ob beim WAL-Checkpoint, wenn die Datei wieder kleiner wird, ein Wakeup ausgelöst wird oder ob der Poller Größenverkleinerungen herausfiltert.

    • Dieser Kommentar ist komplett falsch.
      Die VNODE-Events von kqueue werden zugestellt, solange der Prozess Zugriffsrechte auf die Datei hat; es gibt keinen Filter, der denselben Prozess ausschließt.
    • Das muss tatsächlich getestet werden.
      Ich prüfe das und melde mich dann nochmal.
  • Sehr cool. Ich frage mich, ob der Flaschenhals unter Last vor allem der SQLite-Schreibdurchsatz ist oder eher die WAL-Benachrichtigungsschicht.

    • Der Flaschenhals liegt bei den Writes sowie im Claim/Ack-Flow.
      Das hängt auch stark von Journal Mode und Synchronous Mode ab.
      Die Benachrichtigung ist sowohl mit dem alten stat(2)-Ansatz als auch mit dem neuen PRAGMA-basierten Ansatz sehr günstig. In einem anderen Kommentar wurde stat(2) mit ungefähr 1 µs angegeben.
  • Tolles Projekt. Ich baue ebenfalls etwas, das SQLite weit über den üblichen Einsatzzweck hinaus ausreizt.
    Es ist ermutigend zu sehen, dass mehr Menschen erkunden, was mit SQLite tatsächlich alles möglich ist.

  • Ich frage mich, ob sich das auch integrieren lässt, wenn man SQLAlchemy verwendet.
    So wie es im Moment aussieht, scheint es seine DB-Connection selbst aufbauen zu wollen.