- SQLite 확장과 여러 언어 바인딩을 통해 같은 .db 파일 안에서 durable pub/sub, 작업 큐, 이벤트 스트림을 클라이언트 폴링이나 별도 daemon·broker 없이 함께 처리할 수 있게 만듦
- notify(), stream(), queue()는 모두 호출자의 트랜잭션 안에서 기록되며, 비즈니스 쓰기와 함께 커밋되거나 함께 롤백돼 dual-write 문제를 줄여줌
- 프로세스 간 깨우기는 PRAGMA **data_version**을 1ms마다 확인하는 방식으로 동작하며, 단일 숫자 밀리초 수준 지연과 매우 작은 조회 비용을 목표로 맞춰짐
- 작업 큐는 재시도, 우선순위, 지연 실행, dead-letter, scheduler, named lock, rate limiting을 포함하고, 스트림은 소비자별 오프셋을 저장하는 at-least-once 전달을 지원함
- SQLite를 주 저장소로 쓰는 환경에서 애플리케이션과 비동기 처리를 한 데이터베이스 파일로 묶어 운영 복잡도를 낮추는 구성이며, API는 아직 Experimental 상태임
개요
- SQLite 확장과 여러 언어 바인딩으로 Postgres식 NOTIFY/LISTEN 동작을 SQLite에 더하고, durable pub/sub, 작업 큐, 이벤트 스트림을 클라이언트 폴링이나 별도 daemon·broker 없이 같은 .db 파일 안에서 처리할 수 있게 함
- Rust로 한 번 정의한 온디스크 레이아웃을 바탕으로 Python, Node, Bun, Ruby, Go, Elixir, C++ 바인딩이 모두 같은 loadable extension을 얇게 감싸는 구조로 맞춰짐
- 데이터베이스를 1ms마다 읽는 방식으로 애플리케이션 레벨 폴링을 대체하며, PRAGMA data_version 조회 비용은 단일 숫자 마이크로초 수준이고 프로세스 간 알림 전달은 단일 숫자 밀리초 수준으로 맞춰짐
- SQLite를 주 저장소로 쓰는 경우 비즈니스 쓰기와 큐 적재를 같은 트랜잭션에서 커밋하거나 롤백할 수 있어, 별도 datastore 운영과 dual-write 문제를 줄여줌
- API는 아직 Experimental 상태이며 변경될 수 있음
- 이미 Postgres를 운영 중이라면 pg_notify, pg-boss, Oban 사용이 더 적합함을 분명히 밝힘
주요 기능
- 프로세스 간 notify/listen, 재시도와 우선순위·지연 실행·dead-letter 테이블을 갖춘 작업 큐, 소비자별 오프셋을 갖춘 durable stream을 한 .db 파일에서 함께 제공함
- 모든 send 동작은 비즈니스 쓰기와 원자적으로 결합할 수 있어 함께 커밋되거나 함께 롤백됨
- 교차 프로세스 반응 시간은 단일 숫자 밀리초 수준이며, handler timeout, exponential backoff 기반 재시도, delayed jobs, task expiration, named lock, rate limiting도 포함함
- leader election 기반 scheduler와 crontab 스타일 periodic task, opt-in 방식의 task result 저장도 지원함
- enqueue는 id를 반환하고, worker는 반환값을 저장하며, 호출자는 queue.wait_result(id)로 결과를 기다릴 수 있음
- SQLite loadable extension 형태를 제공해 어떤 SQLite 클라이언트든 같은 테이블을 읽을 수 있음
- ORM이 소유한 SQLite 연결 안에서도 동작하며 ORM 가이드에서 SQLAlchemy, SQLModel, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto 연동을 다룸
- 반대로 의도적으로 넣지 않은 범위도 분명히 해둠
- task pipeline, chain, group, chord는 지원하지 않음
- multi-writer replication은 지원하지 않음
- DAG 기반 workflow orchestration은 지원하지 않음
빠른 시작
-
Python 큐
- honker.open("app.db")로 데이터베이스를 열고 db.queue("emails")처럼 큐를 얻어 작업을 적재하고 소비할 수 있음
- with db.transaction() as tx: 블록 안에서 주문 INSERT와 emails.enqueue(..., tx=tx)를 함께 수행하면 주문 쓰기와 메일 작업 적재가 같은 트랜잭션으로 묶임
- worker는 async for job in emails.claim("worker-1"): 형태로 작업을 하나씩 가져오며, 성공 시 job.ack(), 실패 시 job.retry(delay_s=60, error=str(e))로 처리함
- claim()은 비동기 이터레이터이며 내부적으로 각 반복마다 claim_batch(worker_id, 1)을 호출함
- 데이터베이스의 어떤 커밋에도 깨어나며, commit watcher가 동작하지 못할 때만 5초짜리 paranoia poll로 되돌아감
- 배치 작업은 claim_batch(worker_id, n)과 queue.ack_batch(ids, worker_id)를 직접 쓰도록 분리했으며, 기본 visibility는 300초임
-
Python 태스크
- @emails.task(retries=3, timeout_s=30) 데코레이터를 쓰면 함수 호출이 직접 큐 적재로 바뀌고 TaskResult를 반환함
- 호출 측은 send_email("alice@example.com", "Hi")처럼 사용하고, r.get(timeout=10)으로 worker 실행 결과를 기다릴 수 있음
- worker는 python -m honker worker myapp.tasks:db --queue=emails --concurrency=4처럼 별도 프로세스 또는 in-process로 실행 가능함
- 자동 이름은 {module}.{qualname}이며, 운영 환경에서는 이름 변경으로 pending job이 고아가 되는 일을 막기 위해 @emails.task(name="...") 같은 명시적 이름을 권장함
- periodic task는 @emails.periodic_task(crontab("0 3 * * *")) 형태를 사용함
- 자세한 예시는 packages/honker/examples/tasks.py에 있음
-
Python 스트림
- db.stream("user-events")는 durable pub/sub를 제공하며, 비즈니스 UPDATE와 stream.publish(..., tx=tx)를 같은 트랜잭션에서 수행할 수 있음
- async for event in stream.subscribe(consumer="dashboard"):로 구독하면 저장된 오프셋 이후의 행을 재생한 뒤, 이후에는 커밋 기반 실시간 전달로 전환됨
- 각 named consumer의 오프셋은 _honker_stream_consumers 테이블에 저장됨
- 오프셋 자동 저장은 기본적으로 1000개 이벤트마다 또는 1초마다 한 번만 이뤄져, 높은 처리량에서도 single-writer 슬롯을 과도하게 두드리지 않게 함
- save_every_n=과 save_every_s=로 조정할 수 있고, 둘 다 0으로 두면 자동 저장을 끄고 stream.save_offset(consumer, offset, tx=tx)를 직접 호출할 수 있음
- crash가 나면 마지막 flush된 오프셋 이후의 in-flight 이벤트가 다시 전달되는 at-least-once 모델을 따름
-
Python notify
- async for n in db.listen("orders"):로 ephemeral pub/sub를 구독하고, 트랜잭션 안에서 tx.notify("orders", {"id": 42})로 알림을 보낼 수 있음
- listener는 현재 MAX(id) 지점부터 붙으므로 과거 이력은 재생하지 않음
- durable replay가 필요하면 db.stream()을 써야 함
- notifications 테이블은 자동 정리되지 않으므로 스케줄된 작업에서 db.prune_notifications(older_than_s=…, max_keep=…)를 호출해야 함
- task payload는 JSON으로 유효해야 하며, Python writer와 Node reader가 같은 채널을 공유할 수 있음
-
Node.js
- Node 바인딩에서도 open('app.db'), db.transaction(), tx.notify(...), db.listen('orders') 패턴으로 같은 기능을 사용함
- 비즈니스 쓰기와 notify가 같은 commit에 묶이고, listen은 데이터베이스의 어떤 commit에도 깨어난 뒤 채널로 필터링함
-
SQLite extension
- .load ./libhonker_ext 후 SELECT honker_bootstrap();로 초기화하고 SQL 함수만으로 큐, 락, rate limit, scheduler, stream, result 저장 기능을 사용할 수 있음
- 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, honker_result_save 같은 함수가 제공됨
- Python 바인딩과 extension은 _honker_live, _honker_dead, _honker_notifications를 공유하므로, 다른 언어가 extension으로 넣은 작업을 Python worker가 가져갈 수 있음
- 스키마 호환성은 tests/test_extension_interop.py에 고정돼 있음
설계
- 이 저장소는 honker SQLite loadable extension과 Python, Node, Rust, Go, Ruby, Bun, Elixir 바인딩을 함께 포함함
- SQLite를 주 저장소로 쓰는 애플리케이션을 대상으로 하며, package logic을 SQLite extension으로 옮겨 여러 언어와 프레임워크에서 비슷한 방식으로 쓰게 만드는 데 초점을 둠
- 핵심 primitive는 세 가지임
- ephemeral pub/sub인 notify()
- 소비자별 오프셋을 갖춘 durable pub/sub인 stream()
- at-least-once 작업 큐인 queue()
- 이 세 primitive는 모두 호출자의 트랜잭션 안에서 INSERT로 기록되며, 작업 전송과 비즈니스 쓰기가 같이 커밋되거나 같이 롤백됨
- 목표는 애플리케이션 레벨 폴링 없이 NOTIFY/LISTEN과 비슷한 동작을 구현해 빠른 반응 시간을 만드는 데 있음
- 기존 SQLite 파일을 그대로 쓰면 데이터베이스의 모든 커밋이 worker를 깨우며, 대부분의 trigger는 실제 처리 없이 메시지나 큐를 읽고 빈 결과로 끝날 수 있음
- 이런 overtriggering은 의도된 tradeoff이며, push에 가까운 동작과 빠른 반응 시간을 위해 선택됨
WAL 권장 기본값
- 언어 바인딩은 기본적으로 journal_mode = WAL을 사용하며, 이는 동시 reader와 단일 writer 구조, 효율적인 fsync batching, wal_autocheckpoint = 10000 설정을 제공함
- DELETE, TRUNCATE, MEMORY 같은 다른 모드도 동작하며, 커밋 감지는 모든 journal mode에서 증가하는 PRAGMA data_version을 기반으로 이뤄짐
- 비-WAL 모드에서 잃는 것은 동시 읽기 중 쓰기 특성뿐이며, correctness와 프로세스 간 wake 자체는 WAL에 의존하지 않음
- 시스템 전체는 하나의 .db 파일로 구성되고, WAL을 켠 경우 .db-wal, .db-shm 사이드카가 추가될 수 있음
- claim은 partial index를 통한 한 번의 UPDATE … RETURNING, ack는 한 번의 DELETE로 처리됨
- 어떤 journal mode에서도 한 시점의 writer는 하나뿐이며, 동시 reader 이점은 WAL에서 제공됨
- PRAGMA data_version은 매 커밋과 checkpoint 때 증가하므로 WAL truncation, journal 파일 생성과 제거, 동일 크기 재사용 같은 상황도 올바르게 처리함
- SQLite에는 wire protocol이 없어 서버 푸시는 불가능하며, 소비자는 직접 읽기를 시작해야 함
- wake 신호는 counter 증가
- 이후 실제 조회는 SELECT
- 트랜잭션은 저렴하므로 jobs, events, notifications를 호출자의 열린 with db.transaction() 블록 안에 outbox 패턴처럼 기록함
- stat(2)로 WAL 파일 크기·mtime을 보는 방식이나 FSEvents·inotify·kqueue 같은 kernel watcher 대신 PRAGMA data_version을 사용함
- data_version은 어떤 연결의 커밋이든 SQLite가 증가시키는 monotonic counter임
- WAL truncation, clock skew, rollback된 트랜잭션을 올바르게 처리함
- macOS의 kernel watcher는 같은 프로세스 쓰기를 놓치고, (size, mtime) 기반 stat(2)는 WAL이 truncate됐다가 같은 크기로 다시 커질 때 커밋을 놓칠 수 있음
- Linux, macOS, Windows에서 동일하게 동작하고 1ms 수준 해상도에서 CPU 비용은 매우 작음
- 질의당 비용은 약 3.5µs, 1kHz 기준 총 약 3.5ms/sec라고 적시함
- SQLite 락 모델은 single machine, single writer를 전제로 하며, 두 서버가 NFS 위의 같은 .db에 쓰면 손상됨
- 이런 경우 파일 단위 샤딩 또는 Postgres 전환이 필요함
아키텍처
-
Wake 경로
- Database마다 PRAGMA poll thread 하나를 두고 data_version을 1ms마다 조회함
- counter가 바뀌면 각 subscriber의 bounded channel로 tick을 fan-out함
- 각 subscriber는 partial index를 활용한 SELECT … WHERE id > last_seen을 실행하고 새 행을 반환한 뒤 다시 대기함
- subscriber가 100명이어도 poll thread는 1개만 있으면 됨
- idle listener는 SQL 쿼리를 전혀 실행하지 않음
- idle 비용은 데이터베이스당 1ms마다 한 번의 PRAGMA data_version 질의뿐이며, listener 수는 SQLite counter read를 쓰는 구조 덕분에 거의 공짜에 가깝게 늘어남
- honker-core의 SharedWalWatcher가 poll thread를 소유하고 subscriber id별 bounded SyncSender<()> 채널로 fan-out을 수행함
- 각 db.wal_events() 호출은 subscriber를 등록하고, 반환된 handle이 Drop될 때 자동으로 구독 해제됨
- listener가 drop되면 bridge thread의 rx.recv() -> Err가 발생하고 정리 후 종료됨
-
큐 스키마
- _honker_live에는 pending과 processing 상태의 행이 들어감
- partial index는 (queue, priority DESC, run_at, id) WHERE state IN ('pending','processing') 형태임
- claim은 이 인덱스를 통한 한 번의 UPDATE … RETURNING으로 이뤄짐
- ack는 한 번의 DELETE임
- 재시도 한도를 넘긴 행은 _honker_dead로 이동하며 claim 경로에서는 다시 스캔하지 않음
- state에 대한 partial index 덕분에 claim hot path는 전체 history 크기가 아니라 working set 크기에 의해 제한됨
- dead row가 100k개 있어도 claim 속도는 dead row가 없는 큐와 같게 유지됨
-
Claim 이터레이터
- async for job in q.claim(id)는 claim_batch(id, 1)을 반복 호출하며 작업을 하나씩 내보냄
- Job.ack()는 자체 트랜잭션 안의 단일 DELETE이며, 반환값은 claim이 아직 유효하면 True, visibility window가 지나 다른 worker가 재획득했으면 False가 됨
- 어떤 프로세스의 데이터베이스 commit에도 깨어나며, 5초짜리 paranoia poll이 유일한 fallback임
- 배치 작업은 claim_batch(worker_id, n)과 queue.ack_batch(ids, worker_id)를 직접 사용해야 함
- 라이브러리는 이터레이터 뒤에 배치를 숨기지 않으며, 트랜잭션 비용과 at-most-once visibility 동작을 더 명확하게 다룰 수 있게 함
-
트랜잭션 결합
- notify()는 writer connection에 등록되는 SQL scalar function임
- 호출자의 열린 트랜잭션 아래 _honker_notifications에 INSERT함
- queue.enqueue(…, tx=tx)와 stream.publish(…, tx=tx)도 같은 방식으로 동작함
- rollback이 일어나면 job, event, notification도 함께 사라짐
- 이는 기본 제공 transactional outbox 패턴이며, 별도 library 설치 없이 비즈니스 쓰기와 side effect enqueue를 함께 처리함
- 별도 dispatch table이나 dispatcher process가 없고, side effect row 자체가 커밋된 행이 되며 데이터베이스를 감시하는 어떤 프로세스든 약 1ms 안에 이를 집어갈 수 있음
-
폴링보다 빠른 over-triggering
- data_version 변경은 해당 Database의 모든 subscriber를 깨우며, 커밋된 채널만 선택적으로 깨우지 않음
- 잘못 깨어난 경우의 비용은 indexed SELECT 한 번으로 마이크로초 수준에 그침
- 반대로 깨워야 할 대상을 놓치면 조용한 correctness bug로 이어짐
- 채널 필터링은 trigger 알림 단계가 아니라 SELECT 경로에서 처리됨
- SQLite는 작은 쿼리를 많이 수행하는 패턴도 효율적으로 처리할 수 있음
-
보존 정책
- 큐 작업은 ack될 때까지 남고, 재시도 한도를 넘기면 _honker_dead로 이동함
- stream 이벤트는 유지되며 각 named consumer가 자신의 오프셋을 추적함
- notify는 fire-and-forget이며 자동 정리가 없음
- 보존 정책은 primitive별로 호출자가 선택하며, db.prune_notifications(older_than_s=…, max_keep=…)를 직접 호출해야 함
- 라이브러리 기본값 뒤로 숨기지 않고 호출자 코드에서 retention 정책이 드러나게 하는 방식임
크래시 복구
- rollback은 SQLite ACID 특성에 따라 비즈니스 쓰기와 함께 jobs, events, notifications를 모두 제거함
- 트랜잭션 도중 SIGKILL이 나도 안전하며, 다음 open 때 SQLite의 atomic commit rollback이 stale state를 남기지 않음
- WAL 또는 rollback journal 사용 여부는 journal mode에 따름
- 검증은 tests/test_crash_recovery.py에서 이뤄졌고, subprocess를 COMMIT 전에 종료한 뒤 PRAGMA integrity_check == 'ok'와 새 notify 흐름을 확인함
- worker가 작업 도중 죽으면 visibility_timeout_s가 지나면 다른 worker가 다시 claim함
- 기본값은 300초
- attempts가 증가함
- max_attempts 기본값 3회를 넘기면 행이 _honker_dead로 이동함
- prune 중에 오프라인이던 listener는 제거된 이벤트를 놓치며, durable replay가 필요하면 소비자별 오프셋을 저장하는 db.stream()을 써야 함
웹 프레임워크 연동
- 프레임워크 플러그인은 제공하지 않으며, API가 작아 몇 줄의 glue code로 연결하는 방식을 택함
- FastAPI에서는 startup 시 worker loop를 띄우고, request 처리 중 트랜잭션 안에서 business write와 queue enqueue를 함께 수행하는 예시를 둠
- SSE endpoint는 db.listen(channel) 또는 db.stream(name).subscribe(...) 위에 async def stream(...): yield f"data: ...\n\n" 형태로 약 30줄 정도면 구성 가능함
- Django와 Flask에서는 Celery나 RQ와 같은 패턴으로 worker를 별도 CLI 프로세스로 실행하는 구성을 권장함
ORM 사용
- ORM 연결에서 libhonker_ext를 load하고, ORM 자신의 트랜잭션 안에서 SQL 함수를 호출하면 enqueue가 비즈니스 쓰기와 원자적으로 커밋됨
- SQLAlchemy 예시에서는 connect 이벤트에서 extension을 로드하고 SELECT honker_bootstrap()을 실행한 뒤, s.begin() 트랜잭션 안에서 모델 INSERT와 SELECT honker_enqueue(...)를 함께 호출함
- worker는 honker.open("app.db")를 쓰는 별도 프로세스로 돌며, commit watcher는 같은 파일에 대한 어떤 연결의 commit에도 깨어남
- Using with an ORM 가이드에는 Django, SQLModel, Drizzle, Kysely, sqlx, GORM, ActiveRecord, Ecto 연동과 SQLModel/Pydantic용 TypedQueue[T] wrapper 패턴, Prisma 관련 caveat가 포함됨
성능
- 현대적인 노트북에서 초당 수천 개 메시지를 처리할 수 있다고 밝힘
- 프로세스 간 wake 지연은 1ms poll cadence에 의해 제한되며, M-series 기준 중앙값은 약 1~2ms라고 적시함
- 실제 하드웨어 측정은 bench/wake_latency_bench.py와 bench/real_bench.py로 수행할 수 있음
개발 구성
-
저장소 레이아웃
- honker-core/: 모든 바인딩이 공유하는 Rust rlib이며 in-tree로 포함되고 crates.io에도 배포됨
- honker-extension/: SQLite loadable extension용 cdylib이며 in-tree로 포함되고 crates.io에도 배포됨
- packages/honker/: Python 패키지로 PyO3 cdylib와 Queue, Stream, Outbox, Scheduler를 포함함
- packages/honker-node/: Node.js 바인딩이며 git submodule임
- packages/honker-rs/: Rust용 ergonomic wrapper이며 git submodule임
- packages/honker-go/: Go 바인딩이며 git submodule임
- packages/honker-ruby/: Ruby 바인딩이며 git submodule임
- packages/honker-bun/: Bun 바인딩이며 git submodule임
- packages/honker-ex/: Elixir 바인딩이며 git submodule임
- packages/honker-cpp/: C++ 바인딩이며 git submodule임
- tests/: cross-package integration test 디렉터리임
- bench/: 벤치마크 디렉터리임
- site/: honker.dev 사이트이며 Astro 기반이고 git submodule임
- 각 바인딩 저장소는 PyPI, npm, crates.io, Hex, RubyGems 등에 개별 배포되며, 공통 기반인 honker-core와 honker-extension은 이 저장소 안에 직접 포함됨
- clone 시에는 git clone --recursive 또는 git submodule update --init --recursive가 필요함
테스트와 커버리지
- make test는 기본적으로 Rust, Python, Node 테스트를 실행하며 빠른 경로로 약 10초가 걸림
- make test-python-slow는 soak와 real-time cron 테스트를 포함하며 약 2분이 걸림
- make test-all은 느린 마크를 포함한 전체 테스트를 실행함
- make build는 PyO3 maturin develop와 loadable extension 빌드를 수행함
- 벤치마크는 python bench/wake_latency_bench.py --samples 500, python bench/real_bench.py --workers 4 --enqueuers 2 --seconds 15, python bench/ext_bench.py로 실행할 수 있음
- 커버리지 도구 설치는 make install-coverage-deps를 사용하며, coverage.py와 cargo-llvm-cov를 설치함
- make coverage는 두 HTML 리포트를 coverage/에 생성하고, make coverage-python은 Python 경로, make coverage-rust는 honker-core Rust unit test 기준 리포트를 만듦
- Python 커버리지는 packages/honker/ 기준 약 92%라고 적혀 있음
- Rust 커버리지는 cargo test만 반영하며, honker_ops.rs의 여러 경로는 Python 테스트 스위트로만 실행돼 Rust 리포트에는 잡히지 않음
- PyO3 경계를 넘는 LLVM 프로파일 데이터 병합을 통한 cross-language coverage 결합은 어렵고 아직 미뤄둔 상태임
라이선스
- Apache 2.0 라이선스를 사용함
- 자세한 내용은 LICENSE에 있음

2 days ago
5





![전처 살해 후 시신 유기 시도한 60대 구속…法 "도망 염려" [종합]](https://img.hankyung.com/photo/202604/ZN.43811686.1.jpg)

English (US) ·