Honker - ส่วนขยายที่นำ Postgres NOTIFY/LISTEN มาใช้กับ SQLite
(github.com/russellromney)- รวม durable queue, stream, pub/sub และ scheduler ไว้ในไฟล์ SQLite เพียงไฟล์เดียว ทำให้จัดการงานแบบ asynchronous ได้โดยไม่ต้องมี broker แยกอย่าง Redis หรือ Celery
- ใช้การ polling
PRAGMA data_versionทุก 1ms เพื่อให้ได้เวลาตอบสนองระหว่างโปรเซสในระดับมิลลิวินาทีเลขหลักเดียว โดยไม่ต้องมี application-level polling หรือ daemon notify(),stream(),queue()จะถูก บันทึกภายในทรานแซกชันของผู้เรียก ทั้งหมด จึง commit พร้อมกับการเขียนข้อมูลธุรกิจหรือ rollback พร้อมกัน ช่วยลดปัญหา dual-write- คิวงานรองรับ retry, priority, delayed execution, dead-letter, scheduler, named lock และ rate limiting ส่วนสตรีมรองรับ การส่งแบบ at-least-once โดยเก็บ offset แยกตาม consumer
- ในสภาพแวดล้อมที่ใช้ SQLite เป็นที่เก็บข้อมูลหลัก สามารถรวมแอปพลิเคชันและงาน asynchronous ไว้ใน ไฟล์ฐานข้อมูลเดียว เพื่อลดความซับซ้อนในการปฏิบัติการ
- มี primitive หลัก 3 แบบ
- queue(): คิวงานแบบ at-least-once — retry, priority, delayed job, dead-letter, visibility timeout
- stream(): pub/sub แบบ durable — ติดตาม offset แยกตาม consumer, replay แบบ at-least-once
- notify(): pub/sub แบบชั่วคราว — fire-and-forget, ไม่มีการ replay ประวัติ
- รองรับ ดีคอเรเตอร์
@queue.task()สไตล์ Huey สำหรับแปลงฟังก์ชันเป็นงานในคิว พร้อมรองรับงานตามรอบเวลาผ่านcrontab()+ scheduler แบบ leader election - สคีมาของคิวใช้ partial index กับตาราง
_honker_live, การ claim ใช้UPDATE … RETURNINGเพียงครั้งเดียว และ ack ใช้DELETEเพียงครั้งเดียว ทำให้คงประสิทธิภาพสม่ำเสมอโดยไม่ขึ้นกับจำนวนแถวที่ตายแล้ว - เป็นส่วนขยาย SQLite แบบโหลดได้ (
libhonker_ext) จึงให้ ไคลเอนต์ SQLite 3.9+ ทุกตัว เข้าถึงตารางเดียวกันได้ — Python worker สามารถ claim งานที่ถูก push มาจากภาษาอื่นได้ - มีคู่มือการเชื่อมต่อกับ ORM หลักอย่าง SQLAlchemy, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord และ Ecto
- แม้ทรานแซกชันจะถูกขัดจังหวะระหว่าง SIGKILL ก็ยังปลอดภัยด้วย ACID ของ SQLite และหาก worker ล่ม งานจะถูก claim ใหม่อัตโนมัติเมื่อ visibility timeout หมดอายุ
- มี binding สำหรับ 8 ภาษา ได้แก่ Python, Node.js, Rust, Go, Ruby, Bun, Elixir และ C++ โดยเผยแพร่แยกบน PyPI, npm, crates.io, Hex และ RubyGems
- พัฒนาด้วย Rust (honker-core + honker-extension)
- ไลเซนส์ Apache 2.0
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ผมเป็นคนทำสิ่งนี้เอง Honker เพิ่ม cross-process NOTIFY/LISTEN ให้ SQLite ทำให้ส่งอีเวนต์แบบ push ด้วย latency ระดับเลขหลักเดียวของ ms ได้ โดยใช้แค่ไฟล์ SQLite เดิม ไม่ต้องมี daemon หรือ broker
SQLite ไม่มีเซิร์ฟเวอร์แบบ Postgres ดังนั้นแทนที่จะคิวรีเป็นช่วง ๆ หัวใจสำคัญคือย้ายแหล่ง polling ไปเป็น
stat(2)แบบเบากับไฟล์ WAL SQLite มีประสิทธิภาพอยู่แล้วแม้จะยิงคิวรีเล็ก ๆ จำนวนมาก (https://www.sqlite.org/np1queryprob.html) จึงอาจไม่ถึงกับเป็นการอัปเกรดครั้งใหญ่ แต่ก็น่าสนใจตรงที่แค่เฝ้าดู WAL และเรียกฟังก์ชันของ SQLite ก็พอ ทำให้ไม่ผูกกับภาษาใดภาษาหนึ่งนอกจากนี้ยังมี ephemeral pub/sub, durable work queue ที่มี retry และ dead-letter, และ event stream ที่มี offset แยกต่อ consumer ทั้งสามอย่างนี้เป็น row ในไฟล์
.dbของแอปเดิม จึง commit แบบอะตอมมิก ไปพร้อมกับการเขียน business logic ได้ และถ้า rollback ทั้งคู่ก็หายไปพร้อมกันเดิมมันชื่อ litenotify/joblite แต่พอซื้อ
honker.devแบบขำ ๆ ไว้แล้วก็มานึกได้ว่าชื่ออย่าง Oban, pg-boss, Huey, RabbitMQ, Celery, Sidekiq ต่างก็ฟังตลก ๆ กันทั้งนั้น เลยใช้ชื่อนี้ไปเลย หวังว่าจะมีประโยชน์หรืออย่างน้อยก็ทำให้ขำได้ และคำเตือนว่าเป็นซอฟต์แวร์ระยะอัลฟายังคงใช้เหมือนเดิมในฝั่งอย่าง Java/Go/Clojure/C# นั้น SQLite ก็เป็น single writer อยู่แล้ว ดังนั้นให้แอปพลิเคชันเป็นคนจัดการ writer นั้นเอง แล้วใช้ concurrent queue ระดับภาษาเพื่อรู้ว่ามีการเขียนอะไรเกิดขึ้นและปลุกเฉพาะ thread ที่เกี่ยวข้อง น่าจะง่ายและสะอาดกว่ามาก
ถึงอย่างนั้น การใช้ WAL แบบสร้างสรรค์เช่นนี้ก็น่าสนุก และสำหรับภาษาอย่าง Python/JS/TS/Ruby ที่ concurrency แบบอิง process พบได้บ่อย ก็ดูเข้ากับกลไก notify แบบนี้ไม่น้อย
stat()ทุก 1ms ก็ยังถูกกว่าที่คิดมากบนฮาร์ดแวร์ของผม ใช้เวลาไม่ถึง 1μs ต่อครั้ง ดังนั้น polling ระดับนี้ใช้ CPU ไม่ถึง 0.1%
PRAGMA data_versionน่าจะดีกว่าstat(2)ไหม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
สงสัยว่าสามารถใช้สิ่งนี้เป็น persistent message stream แบบ Kafka ขนาดเบา ได้ไหม เช่นสำหรับ topic หนึ่ง ๆ จะ replay ข้อความทั้งหมดทั้งย้อนหลังและแบบเรียลไทม์ตั้งแต่ timestamp ที่กำหนดได้หรือไม่
คงพอเลียนแบบด้วย polling แบบ pub/sub ได้ แต่ก็คงไม่ใช่วิธีที่เหมาะที่สุดอย่างที่บอก
ถ้าเก็บตำแหน่งการอ่าน ชื่อ queue และ filter เอาไว้ แทนที่จะปลุกทุก subscription thread ทุกครั้งที่
stat(2)เปลี่ยนแล้วให้แต่ละตัวทำ SELECT แบบ N=1 เอง polling thread อาจทำEvents INNER JOIN Subscribersแล้วปลุกเฉพาะ subscriber ที่ match จริง ๆ ได้ขอบคุณสำหรับ feedback ผมเปิด PR ที่รวมข้อเสนอเหล่านั้นแล้ว
https://github.com/russellromney/honker/pulls/1
ตอนนี้เปลี่ยนเป็น โครงสร้าง polling 3 ชั้น:
PRAGMA data_versionทุก 1ms,statทุก 100ms, และการ reconnect เมื่อเกิดข้อผิดพลาดPRAGMA data_versionทุก 1ms แทนการตรวจจับการเปลี่ยนขนาด/mtime แบบstatเดิม มันเป็น commit counter ของ SQLite เองจึงเป็น monotonic ไม่ได้รับผลจาก clock skew และจัดการ WAL truncation หรือ rollback ได้ถูกต้องด้วย เป็น nonblocking query ที่ใช้เวลาประมาณ 3µs และเปลี่ยนเพราะเรื่อง ความถูกต้อง ไม่ใช่เพราะประสิทธิภาพ จริง ๆ แล้วช้ากว่านิดหน่อยด้วยซ้ำ ความเสี่ยงเรื่อง truncation ก็สมจริงกว่าที่คิดจากที่ทดสอบ C API อย่าง
SQLITE_FCNTL_DATA_VERSIONใช้งานข้าม connection ไม่ได้ ตอนนี้จึงยังต้องยอมรับต้นทุนจากการผ่าน VFS layer อยู่ และถือว่าเป็น tradeoff ที่เลือกอย่างชัดเจนdata_versionล้มเหลว ก็จะลอง reconnect โดยสมมติกรณีอย่างดิสก์ขัดข้องชั่วคราว, NFS สะดุด, หรือ connection เสีย และเพื่อความปลอดภัยก็จะปลุก subscriber ด้วยstatเปรียบเทียบ(dev, ino)กับค่าตอน startup เพื่อจับ การสลับไฟล์ เช่น atomic rename, litestream restore, หรือ volume remount เพราะdata_versionจะตาม fd ที่เปิดอยู่ ทำให้ต่อให้ไฟล์เปลี่ยนไปแล้วก็ยังมอง inode เดิมอยู่และจับกรณีนี้ไม่ได้ทำให้ Honker ดีขึ้นมาก และผมเองก็ได้เรียนรู้เยอะ
ขอโปรโมตนิดหนึ่ง ใน PostgreSQL 19 ที่กำลังจะมานั้น LISTEN/NOTIFY ได้รับการปรับแต่งให้สเกลดีขึ้นมากสำหรับ selective signaling
เป็นแพตช์ที่มุ่งไปที่กรณีที่ backend จำนวนมากกำลัง listen คนละ channel กัน
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9
สงสัยว่าถ้าจะเฝ้าดูการเปลี่ยน WAL ด้วย inotify หรือ wrapper ข้ามแพลตฟอร์มโดยไม่ใช้ polling จะได้ไหม
ส่วน
statนั้นใช้งานได้ทุกที่เฉย ๆสิ่งที่น่าสนใจกว่า IPC แยกต่างหากคือมัน commit แบบอะตอมมิกกับข้อมูลธุรกิจ ได้
การส่งข้อความภายนอกมักมีปัญหาแบบ "แจ้งเตือนไปแล้วแต่ทรานแซกชัน rollback" อยู่เสมอ และเรื่องแบบนี้จะรกเร็วมาก
สิ่งที่ผมสงสัยอย่างหนึ่งคือ WAL checkpoint ตอนที่ SQLite truncate WAL กลับไปเป็น 0 การ polling ด้วย
stat()จัดการกรณีนั้นได้ถูกต้องหรือไม่ รู้สึกเหมือนอาจมีช่วงที่พลาดอีเวนต์ได้เมื่อก่อนผมเคยเจอปัญหากับ Postgres+SQS ที่ trigger ส่ง enqueue ออกไปก่อนที่อีก connection จะมองเห็น commit เลยต้องมานั่งเพิ่ม retry logic, เพิ่ม polling ฝั่ง worker แล้วสุดท้ายก็ย้าย enqueue เข้าไปอยู่ในทรานแซกชัน ซึ่งพอทำแบบนั้นก็เท่ากับกลับมาสร้างสิ่งที่ Honker ทำอยู่ใหม่ด้วยชิ้นส่วนที่มากกว่าเดิม
บั๊กประเภท "ส่ง notification ไปแล้วแต่ row ยังไม่ commit" มักจะเงียบและขึ้นกับจังหวะเวลา ทำให้ตามหายากมากจริง ๆ
แต่ตอนนี้ยังไม่มีเทสต์สำหรับส่วนนี้ จึงต้องตรวจสอบเพิ่มอีกหน่อย เป็นประเด็นที่ดี เดี๋ยวจะตามดู
ขอบคุณ
ตอนนี้มีแอปเล็ก ๆ ที่ใช้ SQLite มากขึ้นเรื่อย ๆ และส่วนใหญ่ก็ต้องการ queue กับ scheduler
ผมเองเคยหมุนของหลายอย่างดู แต่ก็มักรู้สึกเสียดายความสง่างามของ โซลูชันสาย Postgres อยู่เสมอ
อันนี้ตั้งใจว่าจะลองใช้ทันที
ถ้าเจอปัญหาก็ฝากเปิด PR หรือ issue ใน repo ได้เลย
ตรงนี้ทำให้อยากใช้ kqueue/FSEvents แต่เท่าที่เข้าใจ Darwin จะทิ้งการแจ้งเตือนจาก process เดียวกัน
ถ้า publisher กับ listener อยู่ใน process เดียวกัน บางครั้ง listener จะไม่ตื่นเลย ทำให้ตามหาปัญหาค่อนข้างเละ
statpolling แม้จะดูไม่สวย แต่สุดท้ายดูเหมือนเป็นวิธีที่ใช้งานได้จริงทุกที่ผมก็สงสัยเหมือนกันว่าตอน WAL checkpoint แล้วไฟล์เล็กลงอีกครั้ง จะมี wakeup เกิดขึ้นหรือเปล่า หรือ poller กรองการลดลงของขนาดไฟล์ทิ้ง
VNODE event ของ kqueue จะถูกส่งถ้า process นั้นมีสิทธิ์เข้าถึงไฟล์ และไม่มี filter อะไรที่ตัดทิ้งเพราะเป็น process เดียวกัน
ผมจะลองเช็กดูแล้วกลับมาบอกอีกที
เจ๋งมาก อยากรู้ว่าตอนรับโหลดหนัก ๆ คอขวดหลักคือ throughput การเขียนของ SQLite หรือว่าเป็น ชั้น WAL notification กันแน่
และก็ขึ้นอยู่กับ journal mode กับ synchronous mode มากด้วย
ส่วน notification ไม่ว่าจะเป็นวิธี
stat(2)เดิมหรือแบบใหม่ที่อิงPRAGMAก็มีต้นทุนต่ำมาก ในคอมเมนต์อื่นก็มีการบอกว่าstat(2)อยู่ราว ๆ 1µsเป็นโปรเจ็กต์ที่ดีมาก ผมเองก็กำลังทำของที่ดัน SQLite ไปไกลกว่าการใช้งานทั่วไปมาก
เห็นคนอื่นสำรวจว่า SQLite ไปได้ไกลแค่ไหนจริง ๆ ก็รู้สึกมีกำลังใจ
สงสัยว่าสามารถรวมเข้ากับกรณีที่ใช้ SQLAlchemy ได้หรือไม่
จากหน้าตาในตอนนี้เหมือนมันพยายามสร้าง DB connection เอง