Honker - SQLite에 Postgres NOTIFY/LISTEN을 구현하는 확장
(github.com/russellromney)- SQLite 파일 하나에 내구성 큐, 스트림, pub/sub, 스케줄러를 통합해 Redis·Celery 같은 별도 브로커 없이 비동기 작업 처리 가능
PRAGMA data_version을 1ms 간격으로 폴링해 프로세스 간 단일 자릿수 밀리초 반응 속도 달성, 애플리케이션 레벨 폴링이나 데몬 불필요notify(),stream(),queue()는 모두 호출자의 트랜잭션 안에서 기록되며, 비즈니스 쓰기와 함께 커밋되거나 함께 롤백돼 dual-write 문제를 줄여줌- 작업 큐는 재시도, 우선순위, 지연 실행, dead-letter, scheduler, named lock, rate limiting을 포함하고, 스트림은 소비자별 오프셋을 저장하는 at-least-once 전달을 지원
- SQLite를 주 저장소로 쓰는 환경에서 애플리케이션과 비동기 처리를 한 데이터베이스 파일로 묶어 운영 복잡도를 낮출수 있음
- 세 가지 핵심 프리미티브 제공
- queue(): at-least-once 작업 큐 — 재시도, 우선순위, 지연 작업, dead-letter, visibility timeout
- stream(): 내구성 pub/sub — 컨슈머별 오프셋 추적, at-least-once 리플레이
- notify(): 일시적 pub/sub — fire-and-forget, 히스토리 리플레이 없음
- Huey 스타일
@queue.task()데코레이터로 함수를 큐 작업으로 변환,crontab()기반 주기적 작업 + 리더 선출 스케줄러 지원 - 큐 스키마는
_honker_live테이블에 partial index 적용, claim은UPDATE … RETURNING하나, ack은DELETE하나로 처리해 dead 행 수와 무관한 일정 성능 - SQLite 로드 가능 확장(
libhonker_ext)으로 모든 SQLite 3.9+ 클라이언트에서 동일 테이블 접근 — Python 워커가 다른 언어에서 푸시한 작업을 클레임 가능 - SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto 등 주요 ORM과 연동 가이드 제공
- SIGKILL 중 트랜잭션도 SQLite ACID로 안전, 워커 크래시 시 visibility timeout 만료 후 자동 재클레임
- Python, Node.js, Rust, Go, Ruby, Bun, Elixir, C++ 8개 언어 바인딩 제공, 각각 PyPI·npm·crates.io·Hex·RubyGems로 독립 게시
- Rust로 구현(honker-core + honker-extension)
- Apache 2.0 라이선스
Hacker News 의견들
-
이걸 직접 만들었음. Honker는 SQLite에 cross-process NOTIFY/LISTEN를 붙여서, 데몬이나 브로커 없이 기존 SQLite 파일만으로 한 자릿수 ms 지연의 push 스타일 이벤트 전달을 해줌
SQLite는 Postgres처럼 서버가 없어서, 일정 주기로 쿼리하는 대신 WAL 파일에 대한 가벼운stat(2)로 폴링 소스를 옮긴 게 핵심임. SQLite는 작은 쿼리를 많이 날려도 효율적이라(https://www.sqlite.org/np1queryprob.html) 엄청난 업그레이드라고 하긴 어렵지만, WAL을 감시하고 SQLite 함수를 호출하기만 하면 되니 언어에 구애받지 않는다는 점이 흥미로움
여기에 ephemeral pub/sub, 재시도와 dead-letter가 있는 durable work queue, 소비자별 오프셋을 가진 event stream도 올려둠. 셋 다 기존 앱의.db파일 안의 row라서 비즈니스 쓰기와 원자적으로 커밋할 수 있고, 롤백되면 둘 다 함께 사라짐
원래는 litenotify/joblite였는데honker.dev를 장난처럼 사두고 보니 Oban, pg-boss, Huey, RabbitMQ, Celery, Sidekiq처럼 다들 이름이 우스꽝스러워서 그냥 이 이름으로 감. 유용하거나 적어도 웃기면 좋겠고, 알파 소프트웨어라는 경고는 그대로 적용됨- 이건 주로 프로세스 기반 동시성만 다루기 쉬운 언어를 위한 용도로 보임
Java/Go/Clojure/C# 같은 쪽에서는 SQLite가 어차피 single writer라서, 애플리케이션이 그 writer를 관리하면서 언어 차원의 concurrent queue로 어떤 쓰기가 일어났는지 알고 관련 스레드들만 깨우는 편이 더 단순하고 깔끔해 보임
그래도 WAL을 이런 식으로 창의적으로 활용한 건 재미있고, Python/JS/TS/Ruby처럼 프로세스 기반 동시성이 흔한 언어에서는 notify 메커니즘으로 꽤 잘 맞아 보임 - 1ms마다 stat() 해도 생각보다 매우 저렴하다는 걸 이번에 알게 됐음
내 하드웨어에서는 호출당 1μs도 안 걸려서, 이 정도 폴링이면 CPU 사용률이 0.1%도 안 됨 - 내가 뭔가 놓친 걸 수도 있지만,
stat(2)보다PRAGMA data_version이 더 낫지 않나 싶음
https://sqlite.org/pragma.html#pragma_data_version
C API라면 더 직접적인SQLITE_FCNTL_DATA_VERSION도 있음
https://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion - 꽤 멋짐. 나도 비슷한 걸 반쯤 만든 적 있음
이걸 가벼운 Kafka처럼 영속 메시지 스트림으로도 쓸 수 있는지 궁금함. 특정 topic에 대해 어떤 timestamp부터 과거+실시간 메시지를 전부 replay하는 식의 의미론도 가능한지 궁금함
pub/sub처럼 폴링으로도 흉내는 낼 수 있겠지만, 말한 대로 최적은 아닐 듯함 - subscriber 상태도 함께 저장하면 더 좋아질 수 있을 듯함
읽기 위치, queue 이름, 필터 같은 걸 저장해두면stat(2)변화 때마다 모든 subscription thread를 깨워서 각자 N=1 SELECT를 하게 하는 대신, polling thread가Events INNER JOIN Subscribers를 해서 실제로 매칭되는 subscriber만 깨울 수 있음
- 이건 주로 프로세스 기반 동시성만 다루기 쉬운 언어를 위한 용도로 보임
-
피드백 고마움. 제안들을 반영한 PR을 올렸음
https://github.com/russellromney/honker/pulls/1
이제 3계층 폴링 구조로 바뀜: 1ms마다PRAGMA data_version, 100ms마다stat, 그리고 오류 시 재연결 처리임- 1ms마다
PRAGMA data_version을 써서 기존stat기반 size/mtime 변경 감지를 대체했음. SQLite 자체의 commit counter라 monotonic하고, clock skew 영향도 없고, WAL truncation이나 rollback도 제대로 처리함. 약 3µs짜리 nonblocking query고, 성능 때문이 아니라 정확성 때문에 바꿨음. 오히려 약간 더 느림. truncation 위험도 생각보다 현실적이었음
테스트해보니 C API의SQLITE_FCNTL_DATA_VERSION은 connection 간에는 동작하지 않았음. 그래서 지금은 여전히 VFS layer를 거치는 비용을 내고 있고, 그 tradeoff를 명시적으로 받아들이는 상태임 data_version쿼리가 실패하면 디스크 일시 오류, NFS hiccup, connection corruption 같은 경우를 가정해 재연결을 시도하고, 예방 차원에서 subscriber도 깨움- 100ms마다
stat으로(dev, ino)를 startup 시점 값과 비교해서 파일 교체를 잡아냄. atomic rename, litestream restore, volume remount 같은 경우인데,data_version은 열린 fd를 따라가므로 파일이 바뀌어도 원래 inode를 계속 보기 때문에 이걸 못 잡음
덕분에 Honker가 더 나아졌고 나도 배운 게 많았음
- 1ms마다
-
슬쩍 홍보하자면, 다가오는 PostgreSQL 19에서는 LISTEN/NOTIFY가 selective signaling에서 훨씬 잘 스케일하도록 최적화됐음
많은 backend가 서로 다른 channel을 listen하는 경우를 겨냥한 패치임
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9- 좋은 홍보였고 주제와도 아주 잘 맞음
-
폴링 없이 inotify나 크로스플랫폼 wrapper로 WAL 변경을 감시하면 안 되나 싶음
- 크로스플랫폼이 깨짐. 특히 Mac에서는 조용히 삼켜버리는 경우가 있어서 신뢰하기 어려움
stat은 그냥 어디서나 동작함
- 크로스플랫폼이 깨짐. 특히 Mac에서는 조용히 삼켜버리는 경우가 있어서 신뢰하기 어려움
-
별도 IPC보다 매력적인 건 비즈니스 데이터와 원자 커밋된다는 점임
외부 메시지 전달은 늘 "알림은 갔는데 트랜잭션은 롤백됨" 문제가 있고, 이게 금방 지저분해짐
하나 궁금한 건 WAL checkpoint임. SQLite가 WAL을 다시 0으로 truncate할 때stat()폴링이 그걸 제대로 처리하는지 모르겠음. 이벤트를 놓치는 구간이 있을 것 같다는 느낌이 듦- 원자성이 사실상 전부라고 봄
예전에 Postgres+SQS 조합에서 enqueue를 다른 connection에 commit이 보이기 전에 trigger로 날려버리는 바람에 고생했음. retry logic을 붙이고, worker 쪽 폴링도 넣고, 결국 enqueue를 transaction 안으로 옮겼는데, 그러고 나면 결국 Honker가 하는 걸 더 많은 moving part로 다시 만드는 셈이었음
"notification은 갔는데 row는 아직 commit 안 됨"류 버그는 대개 조용하고 타이밍 의존적이라 추적이 정말 괴로움 - WAL 파일은 남아 있고 truncate만 되니 그 자체로 update로 잡히긴 함
다만 이 부분 테스트는 아직 없어서 확인은 더 해야겠음. 좋은 포인트라 챙겨보겠음
- 원자성이 사실상 전부라고 봄
-
고마움
SQLite 기반의 작은 앱이 많이 늘어났고, 대부분 queue와 scheduler가 필요함
직접 몇 가지를 굴려보긴 했지만 늘 Postgres 계열 솔루션의 우아함이 아쉬웠음
이건 곧바로 한 번 써볼 생각임- 작은 증식이라는 표현이 내 사이드프로젝트 습관이 만든 군집을 설명하기에 딱 맞음
문제 부딪히면 repo에 PR이나 issue 남겨주면 좋겠음
- 작은 증식이라는 표현이 내 사이드프로젝트 습관이 만든 군집을 설명하기에 딱 맞음
-
여기서 kqueue/FSEvents를 쓰고 싶어지긴 하는데, Darwin은 같은 프로세스의 알림을 떨어뜨린다고 알고 있음
publisher와 listener가 같은 프로세스면 listener가 아예 안 깨어나는 경우가 있어서 추적하기 꽤 지저분함.stat폴링은 못생겨 보여도 결국 어디서나 실제로 동작하는 건 이쪽 같음
WAL checkpoint 때 파일이 다시 줄어들면 wakeup이 발생하는지, 아니면 poller가 size 감소를 필터링하는지도 궁금함- 이 댓글은 완전히 틀렸음
kqueue의 VNODE 이벤트는 그 프로세스가 파일에 접근 권한만 있으면 전달되고, 같은 프로세스라고 걸러지는 필터는 없음 - 이건 실제로 테스트가 필요함
확인해보고 다시 알려주겠음
- 이 댓글은 완전히 틀렸음
-
아주 멋짐. 부하가 걸렸을 때 병목이 주로 SQLite write throughput인지, 아니면 WAL notification layer인지 궁금함
- 병목은 쓰기와 claim/ack 흐름 쪽임
journal mode와 synchronous mode에 따라 많이 달라지기도 함
notification은 예전stat(2)방식이든 새PRAGMA기반이든 매우 저렴함. 다른 댓글에서도stat(2)가 대략 1µs 수준이라고 했음
- 병목은 쓰기와 claim/ack 흐름 쪽임
-
좋은 프로젝트임. 나도 SQLite를 보통 쓰임새보다 훨씬 더 밀어붙이는 걸 만들고 있음
SQLite가 실제로 어디까지 할 수 있는지 더 많은 사람이 탐색하는 걸 보니 고무적임 -
SQLAlchemy를 쓰는 경우에도 통합 가능한지 궁금함
지금 모습만 보면 DB connection을 스스로 만들려는 것처럼 보임